diff --git a/.env.release.draft b/.env.release.draft new file mode 100644 index 00000000..8a0c28e2 --- /dev/null +++ b/.env.release.draft @@ -0,0 +1,82 @@ +# SkillHub production draft for bare-metal deployment. +# Copy this to .env.release and replace every value marked TODO. +# +# Recommended workflow: +# 1. cp .env.release.draft .env.release +# 2. Edit TODO values +# 3. make validate-release-config +# 4. docker compose --env-file .env.release -f compose.release.yml up -d + +# Pin a released image tag in production. +SKILLHUB_VERSION=v0.1.0-beta.5 +SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server +SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web + +# Public HTTPS entrypoint, no trailing slash. +SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com + +# Usually keep empty when web and api are served from the same domain. +SKILLHUB_WEB_API_BASE_URL= +SKILLHUB_API_UPSTREAM=http://server:8080 + +# Keep database and redis local-only on the host unless you explicitly need remote access. +POSTGRES_BIND_ADDRESS=127.0.0.1 +POSTGRES_PORT=5432 +POSTGRES_DB=skillhub +POSTGRES_USER=skillhub +POSTGRES_PASSWORD=TODO_change_to_a_strong_database_password + +REDIS_BIND_ADDRESS=127.0.0.1 +REDIS_PORT=6379 + +# Host ports exposed by the app containers. +API_PORT=8080 +WEB_PORT=80 + +# Must stay true when the public site is behind HTTPS. +SESSION_COOKIE_SECURE=true + +# External object storage. Production should use s3. +SKILLHUB_STORAGE_PROVIDER=s3 + +# Fill with your real S3-compatible endpoint. +# Aliyun OSS example: +# https://oss-cn-shanghai.aliyuncs.com +# AWS S3 example: +# https://s3.ap-east-1.amazonaws.com +# MinIO example: +# https://minio.example.com +SKILLHUB_STORAGE_S3_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com + +# Optional public endpoint for presigned download URLs. +# Leave empty if the same endpoint is externally reachable. +SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT= + +SKILLHUB_STORAGE_S3_BUCKET=skillhub-prod +SKILLHUB_STORAGE_S3_ACCESS_KEY=TODO_fill_real_access_key +SKILLHUB_STORAGE_S3_SECRET_KEY=TODO_fill_real_secret_key +SKILLHUB_STORAGE_S3_REGION=cn-shanghai + +# Aliyun OSS / AWS S3 typically use false. +# Many self-hosted MinIO deployments need true. +SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false + +# Keep false in production unless you intentionally want the app to create the bucket. +SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false +SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M + +# Bootstrap admin is only for first login / first bootstrap. +BOOTSTRAP_ADMIN_ENABLED=true +BOOTSTRAP_ADMIN_USER_ID=docker-admin +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD=TODO_change_to_a_strong_admin_password +BOOTSTRAP_ADMIN_DISPLAY_NAME=Platform Admin +BOOTSTRAP_ADMIN_EMAIL=admin@example.com + +# Usually keep empty and let the backend derive it from SKILLHUB_PUBLIC_BASE_URL. +DEVICE_AUTH_VERIFICATION_URI= + +# Optional GitHub OAuth. +# Leave both empty if you are not enabling GitHub login yet. +OAUTH2_GITHUB_CLIENT_ID= +OAUTH2_GITHUB_CLIENT_SECRET= diff --git a/.env.release.example b/.env.release.example new file mode 100644 index 00000000..dd1ccbab --- /dev/null +++ b/.env.release.example @@ -0,0 +1,55 @@ +# `edge` tracks the latest build from the default branch. +# For deterministic environments, pin a release tag like `v0.1.0`. +SKILLHUB_VERSION=edge +SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server +SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web +POSTGRES_IMAGE=postgres:16-alpine +REDIS_IMAGE=redis:7-alpine + +# Public entrypoint seen by browsers/CLI, no trailing slash. +# Default to localhost so `runtime.sh up` works as a zero-config quickstart. +SKILLHUB_PUBLIC_BASE_URL=http://localhost + +# Frontend usually keeps this empty and proxies to the backend through nginx. +SKILLHUB_WEB_API_BASE_URL= +SKILLHUB_API_UPSTREAM=http://server:8080 + +POSTGRES_BIND_ADDRESS=127.0.0.1 +POSTGRES_PORT=5432 +POSTGRES_DB=skillhub +POSTGRES_USER=skillhub +POSTGRES_PASSWORD=change-this-postgres-password + +REDIS_BIND_ADDRESS=127.0.0.1 +REDIS_PORT=6379 +API_PORT=8080 +WEB_PORT=80 +SESSION_COOKIE_SECURE=false + +# Zero-config runtime validation uses local storage. +# Switch to `s3` and fill the fields below before a real production deployment. +SKILLHUB_STORAGE_PROVIDER=local +SKILLHUB_STORAGE_S3_ENDPOINT=https://oss-cn-example.aliyuncs.com +SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT= +SKILLHUB_STORAGE_S3_BUCKET=skillhub-prod +SKILLHUB_STORAGE_S3_ACCESS_KEY=replace-me +SKILLHUB_STORAGE_S3_SECRET_KEY=replace-me +SKILLHUB_STORAGE_S3_REGION=cn-shanghai +SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false +SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false +SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M + +# Bootstrap local admin account for first login. Rotate or disable after initial setup. +BOOTSTRAP_ADMIN_ENABLED=false +BOOTSTRAP_ADMIN_USER_ID=docker-admin +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD=replace-this-admin-password +BOOTSTRAP_ADMIN_DISPLAY_NAME=Platform Admin +BOOTSTRAP_ADMIN_EMAIL=admin@example.com + +# Optional override. Defaults to ${SKILLHUB_PUBLIC_BASE_URL}/device. +DEVICE_AUTH_VERIFICATION_URI= + +# Optional: configure real GitHub OAuth before exposing the stack to other users. +OAUTH2_GITHUB_CLIENT_ID= +OAUTH2_GITHUB_CLIENT_SECRET= diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..e5ce7df0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +name: Bug Report +description: Report a defect in SkillHub +title: "[Bug] " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps To Reproduce + description: Include commands, requests, or UI flow + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Branch, commit, runtime profile, browser, OS, etc. + - type: textarea + id: api-impact + attributes: + label: API Contract Impact + description: If relevant, include the request path, response shape, and whether `web/src/api/generated/schema.d.ts` appears stale. + - type: textarea + id: logs + attributes: + label: Logs Or Screenshots diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ed8eaeb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security Report + url: https://github.com/iflytek/skillhub/security/advisories/new + about: Do not file public issues for suspected vulnerabilities. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..f3ae637d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature Request +description: Propose a new capability or workflow improvement +title: "[Feature] " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or operator problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed Solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + - type: textarea + id: impact + attributes: + label: Impact + description: Auth, API, migration, deployment, observability, or UX impact + - type: textarea + id: contract + attributes: + label: Contract Or SDK Impact + description: Note whether this proposal changes OpenAPI, generated SDKs, CLI protocol, or operator docs. diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml new file mode 100644 index 00000000..b9bc7c21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/reward-task.yml @@ -0,0 +1,48 @@ +name: 💰 Reward Task +description: Task issue with Reward +title: '[Reward] ' +labels: + - reward +body: + - type: textarea + id: description + attributes: + label: Task description + validations: + required: true + + - type: dropdown + id: currency + attributes: + label: Reward currency + options: + - 'USD $' + - 'CAD C$' + - 'AUD A$' + - 'GBP £' + - 'EUR €' + - 'CNY ¥' + - 'HKD HK$' + - 'TWD NT$' + - 'SGD S$' + - 'KRW ₩' + - 'JPY ¥' + - 'INR ₹' + - 'UAH ₴' + validations: + required: true + + - type: input + id: amount + attributes: + label: Reward amount + validations: + required: true + + - type: input + id: payer + attributes: + label: Reward payer + description: GitHub username of the payer (optional, defaults to issue creator) + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..1e9094b2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +## Summary + +- What changed? +- Why is this needed? + +## Validation + +- [ ] Backend tests passed +- [ ] Frontend typecheck/build passed +- [ ] OpenAPI SDK regenerated or checked when API contracts changed +- [ ] Smoke test run when relevant + +Commands run: + +```bash +# paste commands here +``` + +## Risk + +- User-facing impact: +- Deployment or migration impact: +- Rollback approach: + +## Notes + +- Related issue: +- Follow-up work: +- Docs or operator runbooks updated when behavior changed: diff --git a/.github/scripts/count-reward.ts b/.github/scripts/count-reward.ts new file mode 100644 index 00000000..0bfa8724 --- /dev/null +++ b/.github/scripts/count-reward.ts @@ -0,0 +1,57 @@ +import { $, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const rawTags = + await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`; + +const lastMonth = new Date(); +lastMonth.setMonth(lastMonth.getMonth() - 1); +const lastMonthStr = lastMonth.toJSON().slice(0, 7); + +const rewardTags = rawTags.stdout + .split("\n") + .filter((line) => line.split(/\s+/)[1] >= lastMonthStr) + .map((line) => line.split(/\s+/)[0]); + +let rawYAML = ""; + +for (const tag of rewardTags) + rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + "\n"; + +if (!rawYAML.trim()) + throw new ReferenceError("No reward data is found for the last month."); + +const rewards = YAML.parse(rawYAML) as Reward[]; + +const groupedRewards = Object.groupBy(rewards, ({ payee }) => payee); + +const summaryList = Object.entries(groupedRewards).map(([payee, rewards]) => { + const reward = rewards!.reduce((acc, { currency, reward }) => { + acc[currency] ??= 0; + acc[currency] += reward; + return acc; + }, {} as Record); + + return { + payee, + reward, + accounts: rewards!.map(({ payee: _, ...account }) => account), + }; +}); + +const summaryText = YAML.stringify(summaryList); + +console.log(summaryText); + +const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`; + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + +await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`; +await $`git push origin --tags --no-verify`; + +await $`gh release create ${tagName} --notes ${summaryText}`; diff --git a/.github/scripts/deno.json b/.github/scripts/deno.json new file mode 100644 index 00000000..c406264d --- /dev/null +++ b/.github/scripts/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} diff --git a/.github/scripts/share-reward.ts b/.github/scripts/share-reward.ts new file mode 100644 index 00000000..100116bf --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,116 @@ +import { components } from "npm:@octokit/openapi-types"; +import { $, argv, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const [ + repositoryOwner, + repositoryName, + issueNumber, + payer, // GitHub username of the payer (provided by workflow, defaults to issue creator) + currency, + reward, +] = argv._; + +interface PRMeta { + author: components["schemas"]["simple-user"]; + assignees: components["schemas"]["simple-user"][]; +} + +const graphqlQuery = ` + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + closedByPullRequestsReferences(first: 10) { + nodes { + url + merged + mergeCommit { + oid + } + } + } + } + } + } +`; + +const PR_DATA = await $`gh api graphql \ + -f query=${graphqlQuery} \ + -f owner=${repositoryOwner} \ + -f name=${repositoryName} \ + -F number=${issueNumber} \ + --jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | {url: .url, mergeCommitSha: .mergeCommit.oid}' | head -n 1`; + +const prData = PR_DATA.text().trim(); + +if (!prData) + throw new ReferenceError("No merged PR is found for the given issue number."); + +const { url: PR_URL, mergeCommitSha } = JSON.parse(prData); + +if (!PR_URL || !mergeCommitSha) + throw new Error("Missing required fields in PR data"); + +console.table({ PR_URL, mergeCommitSha }); + +const { author, assignees }: PRMeta = await ( + await $`gh pr view ${PR_URL} --json author,assignees` +).json(); + +function isBotUser(login: string) { + const lowerLogin = login.toLowerCase(); + return ( + lowerLogin.includes("copilot") || + lowerLogin.includes("[bot]") || + lowerLogin === "github-actions[bot]" || + lowerLogin.endsWith("[bot]") + ); +} + +// Filter out Bot users from the list +const allUsers = [author.login, ...assignees.map(({ login }) => login)]; +const users = allUsers.filter((login) => !isBotUser(login)); + +console.log(`All users: ${allUsers.join(", ")}`); +console.log(`Filtered users (excluding bots): ${users.join(", ")}`); + +if (!users[0]) + throw new ReferenceError( + "No real users found (all users are bots). Skipping reward distribution." + ); + +const rewardNumber = parseFloat(reward); + +if (isNaN(rewardNumber) || rewardNumber <= 0) + throw new RangeError( + `Reward amount is not a valid number, can not proceed with reward distribution. Received reward value: ${reward}` + ); + +const averageReward = (rewardNumber / users.length).toFixed(2); + +const list: Reward[] = users.map((login) => ({ + issue: `#${issueNumber}`, + payer: `@${payer}`, + payee: `@${login}`, + currency, + reward: parseFloat(averageReward), +})); +const listText = YAML.stringify(list); + +console.log(listText); + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; +await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`; +await $`git push origin --tags --no-verify`; + +const commentBody = `## Reward data + +\`\`\`yml +${listText} +\`\`\` +`; +await $`gh issue comment ${issueNumber} --body ${commentBody}`; diff --git a/.github/scripts/type.ts b/.github/scripts/type.ts new file mode 100644 index 00000000..e61d2f03 --- /dev/null +++ b/.github/scripts/type.ts @@ -0,0 +1,7 @@ +export interface Reward { + issue: string; + payer: string; + payee: string; + currency: string; + reward: number; +} diff --git a/.github/workflows/claim-issue-reward.yml b/.github/workflows/claim-issue-reward.yml new file mode 100644 index 00000000..79e1e0a9 --- /dev/null +++ b/.github/workflows/claim-issue-reward.yml @@ -0,0 +1,46 @@ +name: Claim Issue Reward +on: + issues: + types: + - closed + +concurrency: + group: claim-issue-reward-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + claim-issue-reward: + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'reward') + permissions: + contents: write + issues: write + pull-requests: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Get Issue details + id: parse_issue + uses: stefanbuck/github-issue-parser@10dcc54158ba4c137713d9d69d70a2da63b6bda3 # v3.2.3 + with: + template-path: ".github/ISSUE_TEMPLATE/reward-task.yml" + + - name: Calculate & Save Reward + env: + GH_TOKEN: ${{ github.token }} + run: | + deno --allow-run --allow-env --allow-read --allow-net=api.github.com \ + .github/scripts/share-reward.ts \ + "${{ github.repository_owner }}" \ + "${{ github.event.repository.name }}" \ + "${{ github.event.issue.number }}" \ + "${{ steps.parse_issue.outputs.issueparser_payer || github.event.issue.user.login }}" \ + "${{ steps.parse_issue.outputs.issueparser_currency }}" \ + "${{ steps.parse_issue.outputs.issueparser_amount }}" diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 00000000..fd59d840 --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,67 @@ +name: PR Tests + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + +concurrency: + group: pr-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + web-tests: + name: Web Build And Test + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Build frontend + run: make build-frontend + + - name: Run frontend unit tests + run: make test-frontend + + server-tests: + name: Server Unit Tests + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Ensure Maven wrapper is executable + run: chmod +x server/mvnw + + - name: Run backend unit tests + run: make test-backend diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..d5351fac --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,158 @@ +name: Publish Images + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + DOCKER_PLATFORMS: linux/amd64,linux/arm64 + +jobs: + publish: + runs-on: ubuntu-latest + env: + MIRROR_REGISTRY: ${{ secrets.MIRROR_REGISTRY || secrets.ALIYUN_REGISTRY }} + MIRROR_NAMESPACE: ${{ secrets.MIRROR_NAMESPACE || secrets.ALIYUN_NAME_SPACE }} + MIRROR_REGISTRY_USERNAME: ${{ secrets.MIRROR_REGISTRY_USERNAME || secrets.ALIYUN_REGISTRY_USER }} + MIRROR_REGISTRY_PASSWORD: ${{ secrets.MIRROR_REGISTRY_PASSWORD || secrets.ALIYUN_REGISTRY_PASSWORD }} + strategy: + fail-fast: false + matrix: + include: + - name: server + context: ./server + dockerfile: ./server/Dockerfile + image: ghcr.io/${{ github.repository_owner }}/skillhub-server + mirror_image: skillhub-server + - name: web + context: ./web + dockerfile: ./web/Dockerfile + image: ghcr.io/${{ github.repository_owner }}/skillhub-web + mirror_image: skillhub-web + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect mirror configuration + id: mirror + shell: bash + run: | + if [[ -n "${MIRROR_REGISTRY}" && -n "${MIRROR_NAMESPACE}" && -n "${MIRROR_REGISTRY_USERNAME}" && -n "${MIRROR_REGISTRY_PASSWORD}" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to mirror registry + if: ${{ steps.mirror.outputs.enabled == 'true' }} + shell: bash + run: | + echo "${MIRROR_REGISTRY_PASSWORD}" | docker login "${MIRROR_REGISTRY}" \ + --username "${MIRROR_REGISTRY_USERNAME}" \ + --password-stdin + + - name: Prepare image targets + id: targets + run: | + { + echo "images<> "$GITHUB_OUTPUT" + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.targets.outputs.images }} + tags: | + type=raw,value=edge,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=ref,event=tag + type=sha,format=short,prefix=sha- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push ${{ matrix.name }} + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: ${{ env.DOCKER_PLATFORMS }} + push: true + provenance: false + sbom: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} + + mirror-runtime-images: + name: Mirror Runtime Images + runs-on: ubuntu-latest + needs: publish + env: + MIRROR_REGISTRY: ${{ secrets.MIRROR_REGISTRY || secrets.ALIYUN_REGISTRY }} + MIRROR_NAMESPACE: ${{ secrets.MIRROR_NAMESPACE || secrets.ALIYUN_NAME_SPACE }} + MIRROR_REGISTRY_USERNAME: ${{ secrets.MIRROR_REGISTRY_USERNAME || secrets.ALIYUN_REGISTRY_USER }} + MIRROR_REGISTRY_PASSWORD: ${{ secrets.MIRROR_REGISTRY_PASSWORD || secrets.ALIYUN_REGISTRY_PASSWORD }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Detect mirror configuration + id: mirror + shell: bash + run: | + if [[ -n "${MIRROR_REGISTRY}" && -n "${MIRROR_NAMESPACE}" && -n "${MIRROR_REGISTRY_USERNAME}" && -n "${MIRROR_REGISTRY_PASSWORD}" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + if: ${{ steps.mirror.outputs.enabled == 'true' }} + uses: docker/setup-buildx-action@v3 + + - name: Log in to mirror registry + if: ${{ steps.mirror.outputs.enabled == 'true' }} + shell: bash + run: | + echo "${MIRROR_REGISTRY_PASSWORD}" | docker login "${MIRROR_REGISTRY}" \ + --username "${MIRROR_REGISTRY_USERNAME}" \ + --password-stdin + + - name: Mirror runtime dependency images + if: ${{ steps.mirror.outputs.enabled == 'true' }} + run: bash scripts/mirror-runtime-images.sh + + - name: Skip mirroring when registry secrets are not configured + if: ${{ steps.mirror.outputs.enabled != 'true' }} + run: echo "Mirror registry secrets are not configured; skipping runtime image mirroring." diff --git a/.github/workflows/statistic-member-reward.yml b/.github/workflows/statistic-member-reward.yml new file mode 100644 index 00000000..c6000053 --- /dev/null +++ b/.github/workflows/statistic-member-reward.yml @@ -0,0 +1,43 @@ +name: Statistic Member Reward +on: + schedule: + - cron: "0 0 1 * *" # Run at 00:00 on the first day of every month + +jobs: + statistic-member-reward: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Check for new commits since last statistic + run: | + last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "") + + if [ -z "$last_tag" ]; then + echo "No previous statistic tags found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + else + new_commits=$(git log $last_tag..HEAD --oneline) + if [ -z "$new_commits" ]; then + echo "No new commits since last statistic tag." + echo "NEW_COMMITS=false" >> $GITHUB_ENV + else + echo "New commits found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + fi + fi + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + if: env.NEW_COMMITS == 'true' + with: + deno-version: v2.x + + - name: Statistic rewards + if: env.NEW_COMMITS == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: deno --allow-run --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts diff --git a/.gitignore b/.gitignore index c0900a12..c3d2dc4b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ Thumbs.db # Editors / IDEs .idea/ .vscode/ +.claude/ *.iml *.swp *.swo @@ -50,10 +51,13 @@ coverage/ .vite/ .eslintcache *.tsbuildinfo +package-lock.json # Temporary files .tmp/ tmp/ +__pycache__/ +*.py[cod] # Git worktrees .worktrees/ @@ -63,3 +67,10 @@ tmp/ # Superpowers (AI planning artifacts) docs/superpowers/ +docs/review/ +CLAUDE.md + +# oh-my-claudecode +.omc + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ddb1e00f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,173 @@ +# SkillHub - Claude Code Instructions + +## Project Overview + +SkillHub is an enterprise-grade, self-hosted agent skill registry that enables teams to publish, discover, and manage reusable skill packages within their organization. It's built for on-premise deployment with full data sovereignty. + +## Tech Stack + +### Backend +- **Language**: Java 21 +- **Framework**: Spring Boot 3.2.3 +- **Architecture**: Multi-module Maven project with clean architecture +- **Modules**: + - `skillhub-app`: Main application entry point + - `skillhub-domain`: Core business logic + - `skillhub-auth`: Authentication and authorization + - `skillhub-search`: Search functionality + - `skillhub-storage`: Storage abstraction layer + - `skillhub-infra`: Infrastructure concerns +- **Database**: PostgreSQL 16 with Flyway migrations +- **Cache**: Redis 7 +- **Storage**: S3/MinIO for skill packages + +### Frontend +- **Language**: TypeScript +- **Framework**: React 19 +- **Build Tool**: Vite +- **Routing**: TanStack Router +- **Data Fetching**: TanStack Query +- **Styling**: Tailwind CSS + Radix UI +- **API Client**: OpenAPI TypeScript (type-safe) +- **i18n**: i18next + +### Infrastructure +- **Containerization**: Docker & Docker Compose +- **Monitoring**: Prometheus + Grafana +- **Deployment**: Kubernetes manifests available +- **CI/CD**: GitHub Actions + +## Development Workflow + +### Starting the Development Environment + +```bash +# Start full local stack (backend + frontend + dependencies) +make dev-all + +# Stop everything +make dev-all-down + +# Reset and start from clean slate +make dev-all-reset +``` + +### Local Access +- Web UI: http://localhost:3000 +- Backend API: http://localhost:8080 + +### Mock Users (Local Development) +- `local-user`: Normal user for publishing and namespace operations +- `local-admin`: Super admin for review and admin flows +- Use `X-Mock-User-Id` header in local development + +### Common Commands + +```bash +make help # Show all available commands +make test # Run backend tests +make typecheck-web # TypeScript type checking +make build-web # Build frontend +make generate-api # Regenerate OpenAPI types +./scripts/check-openapi-generated.sh # Verify API contract sync +./scripts/smoke-test.sh http://localhost:8080 # Run smoke tests +``` + +## Code Conventions + +### Commit Messages +Use conventional commit format: +- `feat(scope): description` - New features +- `fix(scope): description` - Bug fixes +- `docs(scope): description` - Documentation changes +- `refactor(scope): description` - Code refactoring +- `test(scope): description` - Test changes +- `chore(scope): description` - Build/tooling changes + +Examples: +- `feat(auth): add local account login` +- `fix(ops): align smoke test with csrf flow` +- `docs(deploy): clarify runtime image usage` + +**IMPORTANT**: Never add `Co-Authored-By` trailers to commit messages unless explicitly requested by the user. + +### Code Style +- **Backend**: Follow standard Java conventions, Spring Boot best practices +- **Frontend**: Follow TypeScript/React best practices, use functional components +- Keep changes focused - avoid mixing refactors with behavior changes +- Follow existing module boundaries +- Prefer backward-compatible changes unless explicitly allowed + +### Testing +- Add or update tests when behavior changes +- Backend tests must pass before merging +- Frontend typecheck/build must pass when frontend files changed + +### API Contract Management +- When backend API contracts change, regenerate the OpenAPI types: + ```bash + make generate-api + ``` +- Commit the updated `web/src/api/generated/schema.d.ts` +- Run `./scripts/check-openapi-generated.sh` for strict drift checking + +## File Structure + +``` +skillhub/ +├── server/ # Backend (Java/Spring Boot) +│ ├── skillhub-app/ # Main application +│ ├── skillhub-domain/ # Core business logic +│ ├── skillhub-auth/ # Authentication +│ ├── skillhub-search/ # Search functionality +│ ├── skillhub-storage/ # Storage layer +│ └── skillhub-infra/ # Infrastructure +├── web/ # Frontend (React/TypeScript) +│ ├── src/ +│ │ ├── api/generated/ # Auto-generated API types +│ │ └── ... +│ └── ... +├── docs/ # Documentation +├── scripts/ # Utility scripts +├── deploy/ # Deployment configs +├── monitoring/ # Prometheus + Grafana +├── Makefile # Common tasks +└── docker-compose.yml # Local development stack +``` + +## Important Guidelines + +### Before Making Changes +1. Read relevant design docs in `docs/` +2. Open an issue for non-trivial changes before large PRs +3. Check existing patterns and conventions +4. Understand the module boundaries + +### Pull Request Checklist +- Branch is rebased/merged cleanly from target branch +- Relevant backend tests pass +- Frontend typecheck/build passes (if frontend changed) +- API types regenerated (if backend API changed) +- Smoke coverage updated (if operator workflows changed) +- PR description explains motivation, scope, and impact + +### What to Avoid +- Don't mix refactoring with behavior changes +- Don't break module boundaries +- Don't skip API contract regeneration +- Don't make breaking changes without explicit approval +- Don't open public issues for security vulnerabilities + +## Security +- Report security issues privately via GitHub Security Advisories +- Never commit secrets or credentials +- Use environment variables for configuration +- Follow OWASP best practices + +## Documentation +- Full documentation: https://zread.ai/iflytek/skillhub +- Contributing guide: CONTRIBUTING.md +- Code of conduct: CODE_OF_CONDUCT.md + +## License +Apache License 2.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f1dc7ad0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# Code of Conduct + +## Our Standard + +Contributors and maintainers are expected to keep discussion technical, +respectful, and constructive. + +Examples of expected behavior: + +- Focus on the problem, tradeoffs, and evidence. +- Assume good intent, but challenge weak reasoning directly. +- Share actionable feedback. +- Respect different levels of experience and domain knowledge. + +Examples of unacceptable behavior: + +- Harassment, insults, or personal attacks +- Bad-faith argumentation or repeated hostility +- Publishing private or sensitive information without permission +- Disruptive behavior that blocks productive collaboration + +## Enforcement + +Project maintainers may remove comments, reject contributions, or restrict +participation for behavior that violates this code of conduct. + +Serious or repeated violations may result in a temporary or permanent ban from +project spaces. + +## Reporting + +Report conduct issues privately to the maintainers through a private maintainer +channel. Do not use public issues for personal or sensitive reports. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a382e5d7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing to SkillHub + +## Scope + +SkillHub is a self-hosted registry for agent skills. Contributions should +preserve the existing architecture and product direction documented in +[`docs/`](./docs). + +## Before You Start + +- Read [`README.md`](./README.md) for local development commands. +- Check the relevant design docs before changing behavior. +- Open an issue for non-trivial changes before sending a large pull request. + +## Development Setup + +Prerequisites: + +- Docker and Docker Compose +- Java 21 +- Node.js and `pnpm` + +Start the local stack: + +```bash +make dev-all +``` + +Useful commands: + +```bash +make test +make typecheck-web +make build-web +make generate-api +./scripts/check-openapi-generated.sh +./scripts/smoke-test.sh +``` + +Stop the stack: + +```bash +make dev-all-down +``` + +## Change Guidelines + +- Keep changes focused. Avoid mixing refactors with behavior changes. +- Follow existing module boundaries across `server/`, `web/`, and `docs/`. +- Add or update tests when behavior changes. +- Update docs when APIs, auth flows, deployment, or operator workflows change. +- Regenerate and commit `web/src/api/generated/schema.d.ts` when backend OpenAPI + contracts change. +- Prefer backward-compatible changes unless the issue explicitly allows a break. + +## Pull Requests + +Before opening a pull request, make sure: + +- The branch is rebased or merged cleanly from the target branch. +- Relevant backend tests pass. +- Frontend typecheck/build passes when frontend files changed. +- `make generate-api` or `./scripts/check-openapi-generated.sh` has been run when + backend API contracts changed. +- Smoke coverage is updated when operator-facing workflows change. +- The pull request description explains motivation, scope, and rollout impact. + +## Commit Style + +Conventional-style subjects are preferred, for example: + +- `feat(auth): add local account login` +- `fix(ops): align smoke test with csrf flow` +- `docs(deploy): clarify runtime image usage` + +## Reporting Security Issues + +Do not open public issues for suspected security vulnerabilities. + +Use GitHub Security Advisories or your internal security process to report them +privately to the maintainers. diff --git a/LICENSE b/LICENSE index 97d30276..0c5fba18 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2026 iFLYTEK - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets.) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 5417e36f..2f77d71c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api db-reset +.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test check clean web-deps web-install web-install-ci dev-server dev-server-restart dev-web build-backend test-backend build-frontend test-frontend build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid @@ -7,16 +7,27 @@ DEV_SERVER_LOG := $(DEV_DIR)/server.log DEV_WEB_LOG := $(DEV_DIR)/web.log DEV_WEB_URL := http://localhost:3000 DEV_API_URL := http://localhost:8080 -DEV_PROCESS := python3 scripts/dev_process.py +STAGING_API_URL := http://localhost:8080 +STAGING_WEB_URL := http://localhost +STAGING_SERVER_IMAGE := skillhub-server:staging +DEV_PROCESS := bash scripts/dev-process.sh +DEV_SERVER_PREPARE := true +DEV_SERVER_CMD := ./scripts/run-dev-app.sh +BACKEND_TEST_JAVA_OPTIONS ?= -XX:+EnableDynamicAgentLoading +PARALLEL_BASE_REF ?= origin/main +PARALLEL_WORKTREE_ROOT ?= +DEV_COMPOSE_PROJECT_NAME ?= skillhub +STAGING_COMPOSE_PROJECT_NAME ?= skillhub-staging +DEV_COMPOSE := docker compose -p $(DEV_COMPOSE_PROJECT_NAME) +STAGING_BASE_COMPOSE := docker compose -p $(STAGING_COMPOSE_PROJECT_NAME) +STAGING_COMPOSE := $(STAGING_BASE_COMPOSE) -f docker-compose.yml -f docker-compose.staging.yml help: ## 显示帮助 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' dev: ## 启动本地开发环境(仅依赖服务) - docker compose up -d - @echo "Waiting for services to be healthy..." - @sleep 5 + $(DEV_COMPOSE) up -d --wait --remove-orphans @echo "Services ready." @echo "Start backend with: make dev-server" @echo "Start frontend with: make dev-web" @@ -28,13 +39,13 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) echo "Installing frontend dependencies..."; \ $(MAKE) web-install; \ fi - @if [ -f $(DEV_SERVER_PID) ] && kill -0 "$$(cat $(DEV_SERVER_PID))" 2>/dev/null; then \ + @if $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \ else \ echo "Starting backend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null; \ fi - @if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \ + @if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \ echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ @@ -42,13 +53,24 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ - for i in $$(seq 1 60); do \ - if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ - echo "Backend ready."; \ - backend_ready=1; \ - break; \ + for attempt in 1 2; do \ + for i in $$(seq 1 30); do \ + if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ + echo "Backend ready."; \ + backend_ready=1; \ + break 2; \ + fi; \ + if ! $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ + break; \ + fi; \ + sleep 2; \ + done; \ + if [ "$$attempt" -lt 2 ]; then \ + echo "Backend did not become ready on attempt $$attempt. Restarting..."; \ + $(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID); \ + sleep 2; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null; \ fi; \ - sleep 2; \ done; \ if [ "$$backend_ready" -ne 1 ]; then \ echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ @@ -71,15 +93,36 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) @echo "Local environment is ready:" @echo " Web UI: $(DEV_WEB_URL)" @echo " Backend: $(DEV_API_URL)" + @echo "Mock auth users:" + @echo " local-user -> X-Mock-User-Id: local-user" + @echo " local-admin -> X-Mock-User-Id: local-admin" @echo "Logs:" @echo " Backend: $(DEV_SERVER_LOG)" @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local + cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' + +dev-server-restart: ## 重启后端开发服务器 + @mkdir -p $(DEV_DIR) + @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) + @$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null + @echo "Waiting for backend on $(DEV_API_URL) ..." + @for i in $$(seq 1 30); do \ + if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ + echo "Backend ready."; \ + exit 0; \ + fi; \ + sleep 2; \ + done; \ + echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ + exit 1 + +namespace-smoke: ## 运行命名空间工作流 smoke test + ./scripts/namespace-smoke-test.sh $(DEV_API_URL) dev-down: ## 停止本地开发环境 - docker compose down + $(DEV_COMPOSE) down --remove-orphans dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @@ -89,19 +132,53 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID) - docker compose down -v + $(DEV_COMPOSE) down -v --remove-orphans rm -rf $(DEV_DIR) @$(MAKE) dev-all -build: ## 构建后端 +dev-status: ## 查看本地开发服务状态 + @echo "=== Dependency Services ===" + @$(DEV_COMPOSE) ps + @echo "" + @echo "=== Backend ===" + @if $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ + echo " Running (PID $$(cat $(DEV_SERVER_PID)))"; \ + else \ + echo " Not running"; \ + fi + @echo "=== Frontend ===" + @if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \ + echo " Running (PID $$(cat $(DEV_WEB_PID)))"; \ + else \ + echo " Not running"; \ + fi + +dev-logs: ## 实时查看开发服务日志(backend/frontend,默认 backend) + @SERVICE=$${SERVICE:-backend}; \ + if [ "$$SERVICE" = "backend" ]; then \ + tail -f $(DEV_SERVER_LOG); \ + elif [ "$$SERVICE" = "frontend" ]; then \ + tail -f $(DEV_WEB_LOG); \ + else \ + echo "Unknown service: $$SERVICE. Use SERVICE=backend or SERVICE=frontend"; \ + exit 1; \ + fi + +build-backend: ## 构建后端 cd server && ./mvnw clean package -DskipTests -test: ## 运行后端测试 - cd server && ./mvnw test +test-backend: ## 运行后端单元测试 + cd server && JDK_JAVA_OPTIONS="$(BACKEND_TEST_JAVA_OPTIONS)" ./mvnw test + +build: build-backend build-frontend ## 完整构建前后端 + +test: test-backend test-frontend ## 运行前后端完整单元测试 + +check: build test ## 执行前后端完整构建和完整单元测试 clean: ## 清理构建产物 cd server && ./mvnw clean - docker compose down -v + $(DEV_COMPOSE) down -v rm -rf $(DEV_DIR) generate-api: ## 生成 OpenAPI 类型(前端用) @@ -111,15 +188,29 @@ generate-api: ## 生成 OpenAPI 类型(前端用) web-install: ## 安装前端依赖 cd web && pnpm install +web-deps: ## 确保前端依赖可用(本地开发优先复用现有 node_modules) + @if [ -d web/node_modules ]; then \ + echo "Using existing frontend dependencies."; \ + else \ + $(MAKE) web-install-ci; \ + fi + +web-install-ci: ## 以 CI 方式安装前端依赖 + cd web && pnpm run install:ci + dev-web: ## 启动前端开发服务器 cd web && pnpm run dev -build-web: ## 构建前端 +build-frontend: web-deps ## 构建前端 cd web && pnpm run build -test-web: ## 运行前端测试 +test-frontend: web-deps ## 运行前端单元测试 cd web && pnpm run test +build-web: build-frontend ## 构建前端 + +test-web: test-frontend ## 运行前端测试 + typecheck-web: ## 前端类型检查 cd web && pnpm run typecheck @@ -127,8 +218,98 @@ lint-web: ## 前端代码检查 cd web && pnpm run lint db-reset: ## 重置数据库 - docker compose down -v - docker compose up -d postgres - @echo "Waiting for postgres..." - @sleep 3 + $(DEV_COMPOSE) down -v --remove-orphans + $(DEV_COMPOSE) up -d --wait --remove-orphans postgres cd server && ./mvnw flyway:migrate -pl skillhub-app + +validate-release-config: ## 校验发布环境变量文件(默认 .env.release) + ./scripts/validate-release-config.sh .env.release + +staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件) + @echo "=== [1/5] Building backend JAR and Docker image ===" + cd server && ./mvnw package -DskipTests -B -q + docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile.dev server + @echo "=== [2/5] Building frontend static files ===" + cd web && pnpm run build + @echo "=== [3/5] Starting dependency services ===" + $(STAGING_BASE_COMPOSE) up -d --wait + @echo "=== [4/5] Starting staging services ===" + $(STAGING_COMPOSE) up -d --wait server web + @echo "=== [5/5] Running smoke tests ===" + @if bash scripts/smoke-test.sh $(STAGING_API_URL); then \ + echo ""; \ + echo "Staging passed. Environment is running:"; \ + echo " Web UI: $(STAGING_WEB_URL)"; \ + echo " Backend: $(STAGING_API_URL)"; \ + echo ""; \ + echo "Run 'make staging-down' to stop."; \ + echo "Run 'make pr' to create a pull request."; \ + else \ + echo ""; \ + echo "Smoke tests FAILED. Printing logs..."; \ + $(STAGING_COMPOSE) logs server; \ + $(MAKE) staging-down; \ + exit 1; \ + fi + +staging-down: ## 停止 staging 环境 + $(STAGING_COMPOSE) down --remove-orphans + +staging-logs: ## 查看 staging 服务日志(SERVICE=server|web,默认 server) + @SERVICE=$${SERVICE:-server}; \ + $(STAGING_COMPOSE) logs -f $$SERVICE + +pr: ## 推送当前分支并创建 Pull Request(需要 gh CLI,仅限交互式终端) + @if ! command -v gh >/dev/null 2>&1; then \ + echo "Error: gh CLI not found. Install from https://cli.github.com/"; \ + exit 1; \ + fi + @if ! gh auth status >/dev/null 2>&1; then \ + echo "Error: gh CLI not authenticated. Run: gh auth login"; \ + exit 1; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$BRANCH" = "main" ] || [ "$$BRANCH" = "master" ]; then \ + echo "Error: Cannot create PR from main/master branch."; \ + exit 1; \ + fi + @if ! git diff --quiet || ! git diff --cached --quiet; then \ + echo "You have uncommitted changes:"; \ + git status --short; \ + echo ""; \ + printf "Commit all changes before creating PR? [y/N] "; \ + read -r answer; \ + if [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]; then \ + git add -A; \ + git commit -m "chore: pre-PR commit"; \ + else \ + echo "Aborted. Commit or stash your changes first."; \ + exit 1; \ + fi; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + echo "Pushing branch $$BRANCH to origin..."; \ + git push -u origin "$$BRANCH" + @echo "Creating pull request..." + @if gh pr view >/dev/null 2>&1; then \ + echo "A pull request already exists for this branch:"; \ + gh pr view --json url -q '.url'; \ + exit 0; \ + fi + @gh pr create --fill --web || gh pr create --fill + +parallel-init: ## 创建 Claude/Codex/integration 并行 worktree(TASK=) + @if [ -z "$(TASK)" ]; then \ + echo "Usage: make parallel-init TASK= [PARALLEL_BASE_REF=origin/main] [PARALLEL_WORKTREE_ROOT=/path]"; \ + exit 1; \ + fi + ./scripts/parallel-init.sh "$(TASK)" "$(PARALLEL_BASE_REF)" "$(PARALLEL_WORKTREE_ROOT)" + +parallel-sync: ## 在 integration worktree 合并 Claude/Codex 分支(自动识别当前 task) + PARALLEL_WORKTREE_ROOT="$(PARALLEL_WORKTREE_ROOT)" ./scripts/parallel-sync.sh $(SOURCES) + +parallel-up: ## 在 integration worktree 合并并启动联调环境(自动识别当前 task) + PARALLEL_WORKTREE_ROOT="$(PARALLEL_WORKTREE_ROOT)" ./scripts/parallel-up.sh $(SOURCES) + +parallel-down: ## 在 integration worktree 停止联调环境 + ./scripts/parallel-down.sh diff --git a/README.md b/README.md index c0695001..692be941 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,28 @@ -# SkillHub +
+ SkillHub Logo +

SkillHub

+

An enterprise-grade, open-source agent skill registry — publish, discover, and manage reusable skill packages across your organization.

+
-An enterprise-grade agent skill registry — publish, discover, and -manage reusable skill packages across your organization. +
+ +[![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iflytek/skillhub) +[![Docs](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) +[![Build](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) +[![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) +[![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/) +[![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev) + +
+ +
+ +[English](./README.md) | [中文](./README_zh.md) + +
+ +--- SkillHub is a self-hosted platform that gives teams a private, governed place to share agent skills. Publish a skill package, push @@ -9,6 +30,8 @@ it to a namespace, and let others find it through search or install it via CLI. Built for on-premise deployment behind your firewall, with the same polish you'd expect from a public registry. +📖 **[Full Documentation →](https://zread.ai/iflytek/skillhub)** + ## Highlights - **Self-Hosted & Private** — Deploy on your own infrastructure. @@ -24,15 +47,40 @@ firewall, with the same polish you'd expect from a public registry. Each namespace has its own members, roles (Owner / Admin / Member), and publishing policies. - **Review & Governance** — Team admins review within their namespace; - platform admins gate promotions to the global scope. Every - action is audit-logged for compliance. + platform admins gate promotions to the global scope. Governance + actions are audit-logged for compliance. +- **Social Features** — Star skills, rate them, and track downloads. + Build a community around your organization's best practices. +- **Account Merging** — Consolidate multiple OAuth identities and + API tokens under a single user account. +- **API Token Management** — Generate scoped tokens for CLI and + programmatic access with prefix-based secure hashing. - **CLI-First** — Native REST API plus a compatibility layer for - existing ClawHub CLI tools — no client changes needed. + existing ClawHub-style registry clients. Native CLI APIs are the + primary supported path while protocol compatibility continues to + expand. - **Pluggable Storage** — Local filesystem for development, S3 / MinIO for production. Swap via config. +- **Internationalization** — Multi-language support with i18next. ## Quick Start +Start the full local stack with one of the following commands: + +Official images: +```bash +rm -rf /tmp/skillhub-runtime +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +``` + +Aliyun mirror shortcut: +```bash +rm -rf /tmp/skillhub-aliyun +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --home /tmp/skillhub-aliyun --aliyun --version latest +``` + +If deployment runs into problems, clear the existing runtime home and retry. + ### Prerequisites - Docker & Docker Compose @@ -48,6 +96,19 @@ Then open: - Web UI: `http://localhost:3000` - Backend API: `http://localhost:8080` +Local profile seeds two mock-auth users automatically: + +- `local-user` for normal publishing and namespace operations +- `local-admin` with `SUPER_ADMIN` for review and admin flows + +Use them with the `X-Mock-User-Id` header in local development. + +The backend can bootstrap a local-login super admin for first-time access +when you explicitly set `BOOTSTRAP_ADMIN_ENABLED=true`: + +- username: `BOOTSTRAP_ADMIN_USERNAME` (`admin` by default) +- password: `BOOTSTRAP_ADMIN_PASSWORD` (`ChangeMe!2026` by default) + Stop everything with: ```bash @@ -62,11 +123,146 @@ make dev-all-reset Run `make help` to see all available commands. +For the full development workflow (local dev → staging → PR), see [docs/dev-workflow.md](docs/dev-workflow.md). + +### API Contract Sync + +OpenAPI types for the web client are checked into the repository. +When backend API contracts change, regenerate the SDK and commit the +updated generated file: + +```bash +make generate-api +``` + +For a stricter end-to-end drift check, run: + +```bash +./scripts/check-openapi-generated.sh +``` + +This starts local dependencies, boots the backend, regenerates the +frontend schema, and fails if the checked-in SDK is stale. + +### Container Runtime + +Published runtime images are built by GitHub Actions and pushed to GHCR. +This is the supported path for anyone who wants a ready-to-use local +environment without building the backend or frontend on their machine. +Published images target both `linux/amd64` and `linux/arm64`. + +1. Copy the runtime environment template. +2. Pick an image tag. +3. Start the stack with Docker Compose. + +```bash +cp .env.release.example .env.release +``` + +Recommended image tags: + +- `SKILLHUB_VERSION=edge` for the latest `main` build +- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release + +Start the runtime: + +```bash +make validate-release-config +docker compose --env-file .env.release -f compose.release.yml up -d +``` + +Then open: + +- Web UI: `SKILLHUB_PUBLIC_BASE_URL` 对应的地址 +- Backend API: `http://localhost:8080` + +Stop it with: + +```bash +docker compose --env-file .env.release -f compose.release.yml down +``` + +The runtime stack uses its own Compose project name, so it does not +collide with containers from `make dev-all`. + +The production Compose stack now defaults to the `docker` profile only. +It does not enable local mock auth. Bootstrap admin is disabled by default; +if you turn it on explicitly, the backend seeds a local admin account from +environment variables for the first login: + +- username: `BOOTSTRAP_ADMIN_USERNAME` +- password: `BOOTSTRAP_ADMIN_PASSWORD` + +Recommended production baseline: + +- set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint +- keep PostgreSQL / Redis bound to `127.0.0.1` +- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` +- keep `BOOTSTRAP_ADMIN_ENABLED=false` unless you intentionally need bootstrap login +- rotate or disable the bootstrap admin after initial setup +- run `make validate-release-config` before `docker compose up -d` + +If the GHCR package remains private, run `docker login ghcr.io` before +`docker compose up -d`. + +### Monitoring + +A Prometheus + Grafana monitoring stack lives under [`monitoring/`](./monitoring). +It scrapes the backend's Actuator Prometheus endpoint. + +Start it with: + +```bash +cd monitoring +docker compose -f docker-compose.monitoring.yml up -d +``` + +Then open: + +- Prometheus: `http://localhost:9090` +- Grafana: `http://localhost:3001` (`admin` / `admin`) + +By default Prometheus scrapes `http://host.docker.internal:8080/actuator/prometheus`, +so start the backend locally on port `8080` first. + +## Kubernetes + +Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s): + +- `configmap.yaml` +- `secret.yaml.example` +- `backend-deployment.yaml` +- `frontend-deployment.yaml` +- `services.yaml` +- `ingress.yaml` + +Apply them after creating your own secret: + +```bash +kubectl apply -f deploy/k8s/configmap.yaml +kubectl apply -f deploy/k8s/secret.yaml +kubectl apply -f deploy/k8s/backend-deployment.yaml +kubectl apply -f deploy/k8s/frontend-deployment.yaml +kubectl apply -f deploy/k8s/services.yaml +kubectl apply -f deploy/k8s/ingress.yaml +``` + +## Smoke Test + +A lightweight smoke test script is available at [`scripts/smoke-test.sh`](./scripts/smoke-test.sh). + +Run it against a local backend: + +```bash +./scripts/smoke-test.sh http://localhost:8080 +``` + ## Architecture ``` ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ Web UI │ │ CLI Tools │ │ REST API │ +│ (React 19) │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ │ │ │ └───────────────────┼───────────────────┘ @@ -77,20 +273,85 @@ Run `make help` to see all available commands. │ ┌──────▼──────┐ │ Spring Boot │ Auth · RBAC · Core Services + │ (Java 21) │ OAuth2 · API Tokens · Audit └──────┬──────┘ │ ┌────────────┼────────────┐ │ │ │ - ┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐ - │PostgreSQL│ │ Redis │ │ MinIO │ - └──────────┘ └──────────┘ └────────┘ + ┌──────▼───┐ ┌─────▼────┐ ┌────▼────┐ + │PostgreSQL│ │ Redis │ │ Storage │ + │ 16 │ │ 7 │ │ S3/MinIO│ + └──────────┘ └──────────┘ └─────────┘ ``` +**Backend (Spring Boot 3.2.3, Java 21):** +- Multi-module Maven project with clean architecture +- Modules: app, domain, auth, search, storage, infra +- PostgreSQL 16 with Flyway migrations +- Redis for session management +- S3/MinIO for skill package storage + +**Frontend (React 19, TypeScript, Vite):** +- TanStack Router for routing +- TanStack Query for data fetching +- Tailwind CSS + Radix UI for styling +- OpenAPI TypeScript for type-safe API client +- i18next for internationalization + +## Usage with Agent Platforms + +SkillHub works as a skill registry backend for several agent platforms. Point any of the clients below at your SkillHub instance to publish, discover, and install skills. + +### [OpenClaw](https://github.com/openclaw/openclaw) + +[OpenClaw](https://github.com/openclaw/openclaw) is an open-source agent skill CLI. Configure it to use your SkillHub endpoint as the registry: + +```bash +# Configure registry URL +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN + +# Search and install skills +npx clawhub search email +npx clawhub install my-skill +npx clawhub install my-namespace--my-skill + +# Publish a skill +npx clawhub publish ./my-skill +``` + +📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** + +### [AstronClaw](https://agent.xfyun.cn/astron-claw) + +[AstronClaw](https://agent.xfyun.cn/astron-claw) is the skill marketplace provided by iFlytek's Astron platform. You can connect it to a self-hosted SkillHub registry to manage and distribute private skills within your organization, or browse publicly shared skills on the Astron platform. + +### [astron-agent](https://github.com/iflytek/astron-agent) + +[astron-agent](https://github.com/iflytek/astron-agent) is the iFlytek Astron agent framework. Skills stored in SkillHub can be referenced and loaded directly by astron-agent, enabling a governed, versioned skill lifecycle from development to production. + +--- + +> 🌟 **Show & Tell** — Have you built something with SkillHub? We'd love to hear about it! +> Share your use case, integration, or deployment story in the +> [**Discussions → Show and Tell**](https://github.com/iflytek/skillhub/discussions/categories/show-and-tell) category. + ## Contributing Contributions are welcome. Please open an issue first to discuss what you'd like to change. +- Contribution guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md) +- Code of conduct: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) + +## 📞 Support + +- 💬 **Community Discussion**: [GitHub Discussions](https://github.com/iflytek/skillhub/discussions) +- 🐛 **Bug Reports**: [Issues](https://github.com/iflytek/skillhub/issues) +- 👥 **WeChat Work Group**: + + ![WeChat Work Group](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png) + ## License -MIT +Apache License 2.0 diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..ee2e5b6d --- /dev/null +++ b/README_zh.md @@ -0,0 +1,308 @@ +
+ SkillHub Logo +

SkillHub

+

企业级开源智能体技能注册中心 — 在组织内发布、发现和管理可复用的技能包

+
+ +
+ +[![文档](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![许可证](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) +[![构建](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) +[![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) +[![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/) +[![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev) + +
+ +--- + +SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。 + +📖 **[完整文档 →](https://zread.ai/iflytek/skillhub)** + +## 核心特性 + +- **自托管与私有化** — 部署在您自己的基础设施上。将专有技能保留在防火墙后,完全掌控数据主权。一条 `make dev-all` 命令即可在本地运行。 +- **发布与版本管理** — 上传智能体技能包,支持语义化版本控制、自定义标签(`beta`、`stable`)和自动 `latest` 跟踪。 +- **发现** — 全文搜索,支持按命名空间、下载量、评分和时间筛选。可见性规则确保用户只能看到其有权访问的内容。 +- **团队命名空间** — 在团队或全局范围下组织技能。每个命名空间拥有自己的成员、角色(Owner / Admin / Member)和发布策略。 +- **审核与治理** — 团队管理员在其命名空间内审核;平台管理员控制向全局范围的推广。治理操作记录审计日志以满足合规要求。 +- **社交功能** — 收藏技能、评分并跟踪下载量。围绕组织的最佳实践构建社区。 +- **账户合并** — 将多个 OAuth 身份和 API 令牌整合到单个用户账户下。 +- **API 令牌管理** — 为 CLI 和程序化访问生成作用域令牌,采用基于前缀的安全哈希。 +- **CLI 优先** — 原生 REST API,加上对现有 ClawHub 风格注册中心客户端的兼容层。原生 CLI API 是主要支持路径,协议兼容性持续扩展中。 +- **可插拔存储** — 开发环境使用本地文件系统,生产环境使用 S3 / MinIO。通过配置切换。 +- **国际化** — 使用 i18next 支持多语言。 + +## 快速开始 + +使用以下命令之一启动完整的本地环境: + +官方镜像: +```bash +rm -rf /tmp/skillhub-runtime +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +``` + +阿里云镜像快捷方式: +```bash +rm -rf /tmp/skillhub-aliyun +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --home /tmp/skillhub-aliyun --aliyun --version edge +``` + +如果部署遇到问题,请清除现有的运行时目录并重试。 + +### 前置要求 + +- Docker & Docker Compose + +### 访问应用 + +- Web UI: http://localhost:3000 +- 后端 API: http://localhost:8080 + +### 默认账户 + +本地开发环境提供两个模拟用户: + +- `local-user` — 普通用户,用于发布和命名空间操作 +- `local-admin` — 超级管理员,用于审核和管理流程 + +在本地开发中使用 `X-Mock-User-Id` 请求头切换用户。 + +### 停止服务 + +```bash +# 使用官方镜像 +/tmp/skillhub-runtime/runtime.sh down + +# 使用阿里云镜像 +/tmp/skillhub-aliyun/runtime.sh down +``` + +## 开发 + +### 前置要求 + +- Java 21+ +- Node.js 20+ +- Docker & Docker Compose +- Make + +### 启动开发环境 + +```bash +# 克隆仓库 +git clone https://github.com/iflytek/skillhub.git +cd skillhub + +# 启动完整的本地开发栈(后端 + 前端 + 依赖) +make dev-all + +# 或者分别启动 +make dev-backend # 仅后端 +make dev-web # 仅前端 +``` + +### 常用命令 + +```bash +make help # 显示所有可用命令 +make test # 运行后端测试 +make typecheck-web # TypeScript 类型检查 +make build-web # 构建前端 +make generate-api # 重新生成 OpenAPI 类型 +./scripts/check-openapi-generated.sh # 验证 API 契约同步 +./scripts/smoke-test.sh http://localhost:8080 # 运行冒烟测试 +``` + +### 项目结构 + +``` +skillhub/ +├── server/ # 后端(Java/Spring Boot) +│ ├── skillhub-app/ # 主应用程序 +│ ├── skillhub-domain/ # 核心业务逻辑 +│ ├── skillhub-auth/ # 认证授权 +│ ├── skillhub-search/ # 搜索功能 +│ ├── skillhub-storage/ # 存储层 +│ └── skillhub-infra/ # 基础设施 +├── web/ # 前端(React/TypeScript) +├── docs/ # 文档 +├── scripts/ # 实用脚本 +├── deploy/ # 部署配置 +├── monitoring/ # Prometheus + Grafana +├── Makefile # 常用任务 +└── docker-compose.yml # 本地开发栈 +``` + +## 部署 + +### 使用 Docker Compose + +```bash +# 使用官方镜像 +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up + +# 使用阿里云镜像 +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun +``` + +### 使用 Kubernetes + +```bash +# 应用 Kubernetes 清单 +kubectl apply -f deploy/k8s/ + +# 或使用 Helm(即将推出) +helm install skillhub ./deploy/helm +``` + +### 环境变量 + +关键配置选项: + +```bash +# 数据库 +SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/skillhub +SPRING_DATASOURCE_USERNAME=skillhub +SPRING_DATASOURCE_PASSWORD=skillhub + +# Redis +SPRING_DATA_REDIS_HOST=localhost +SPRING_DATA_REDIS_PORT=6379 + +# 存储(S3/MinIO) +STORAGE_TYPE=s3 +STORAGE_S3_ENDPOINT=http://localhost:9000 +STORAGE_S3_ACCESS_KEY=minioadmin +STORAGE_S3_SECRET_KEY=minioadmin +STORAGE_S3_BUCKET=skillhub + +# 认证 +AUTH_JWT_SECRET=your-secret-key +AUTH_SESSION_TIMEOUT=30m +``` + +完整配置参考请查看 [`application.yml`](./server/skillhub-app/src/main/resources/application.yml)。 + +## 架构 + +SkillHub 采用清晰的分层架构: + +- **表现层**:REST API(Spring Boot)+ React 前端 +- **应用层**:用例编排和 DTO 转换 +- **领域层**:核心业务逻辑和实体 +- **基础设施层**:数据库、存储、搜索 + +关键设计决策: + +- **多模块 Maven 项目**:清晰的模块边界和依赖管理 +- **领域驱动设计**:丰富的领域模型和业务规则 +- **CQRS 模式**:读写分离以优化性能 +- **事件溯源**:审计日志和治理操作 +- **可插拔存储**:通过配置在本地/S3/MinIO 之间切换 + +详细架构文档请参阅 [`docs/`](./docs/) 目录。 + +## 技术栈 + +### 后端 +- **语言**:Java 21 +- **框架**:Spring Boot 3.2.3 +- **数据库**:PostgreSQL 16 + Flyway 迁移 +- **缓存**:Redis 7 +- **存储**:S3/MinIO +- **搜索**:PostgreSQL 全文搜索 + +### 前端 +- **语言**:TypeScript +- **框架**:React 19 +- **构建工具**:Vite +- **路由**:TanStack Router +- **数据获取**:TanStack Query +- **样式**:Tailwind CSS + Radix UI +- **API 客户端**:OpenAPI TypeScript(类型安全) +- **国际化**:i18next + +### 基础设施 +- **容器化**:Docker & Docker Compose +- **监控**:Prometheus + Grafana +- **部署**:Kubernetes 清单 +- **CI/CD**:GitHub Actions + +## 路线图 + +- [x] 核心技能注册功能 +- [x] 命名空间和团队管理 +- [x] 审核和治理工作流 +- [x] 全文搜索和筛选 +- [x] 社交功能(收藏、评分、下载) +- [x] API 令牌管理 +- [x] 账户合并 +- [x] 国际化支持 +- [ ] Helm Chart 部署 +- [ ] 高级搜索过滤器 +- [ ] 技能依赖管理 +- [ ] Webhook 集成 +- [ ] 审计日志导出 +- [ ] LDAP/SAML 集成 + +完整路线图请参阅 [`docs/10-delivery-roadmap.md`](./docs/10-delivery-roadmap.md)。 + +## 与智能体平台集成 + +SkillHub 设计为与各种智能体平台和框架无缝集成。 + +### [OpenClaw](https://github.com/openclaw/openclaw) + +[OpenClaw](https://github.com/openclaw/openclaw) 是开源的智能体技能 CLI 工具。配置它使用您的 SkillHub 端点作为注册中心: + +```bash +# 配置注册中心地址 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN + +# 搜索和安装技能 +npx clawhub search email +npx clawhub install my-skill +npx clawhub install my-namespace--my-skill + +# 发布技能 +npx clawhub publish ./my-skill +``` + +📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)** + +### [AstronClaw](https://agent.xfyun.cn/astron-claw) + +[AstronClaw](https://agent.xfyun.cn/astron-claw) 是科大讯飞星火平台提供的技能市场。您可以将其连接到自托管的 SkillHub 注册中心,在组织内管理和分发私有技能,或在星火平台上浏览公开共享的技能。 + +### [astron-agent](https://github.com/iflytek/astron-agent) + +[astron-agent](https://github.com/iflytek/astron-agent) 是科大讯飞星火智能体框架。存储在 SkillHub 中的技能可以被 astron-agent 直接引用和加载,实现从开发到生产的受治理、版本化的技能生命周期。 + +--- + +> 🌟 **展示与分享** — 您使用 SkillHub 构建了什么?我们很想听听! +> 在 [**Discussions → Show and Tell**](https://github.com/iflytek/skillhub/discussions/categories/show-and-tell) 分类中分享您的用例、集成或部署故事。 + +## 贡献 + +欢迎贡献。请先开启 issue 讨论您想要更改的内容。 + +- 贡献指南:[`CONTRIBUTING.md`](./CONTRIBUTING.md) +- 行为准则:[`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) + +## 📞 支持 + +- 💬 **社区讨论**:[GitHub Discussions](https://github.com/iflytek/skillhub/discussions) +- 🐛 **Bug 报告**:[Issues](https://github.com/iflytek/skillhub/issues) +- 👥 **企业微信群**: + + ![企业微信群](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png) + +## 许可证 + +Apache License 2.0 diff --git a/compose.release.yml b/compose.release.yml new file mode 100644 index 00000000..8a9aa4ca --- /dev/null +++ b/compose.release.yml @@ -0,0 +1,103 @@ +services: + postgres: + image: ${POSTGRES_IMAGE:-postgres:16-alpine} + restart: unless-stopped + ports: + - "${POSTGRES_BIND_ADDRESS:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-skillhub} + POSTGRES_USER: ${POSTGRES_USER:-skillhub} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skillhub} -d ${POSTGRES_DB:-skillhub}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: ${REDIS_IMAGE:-redis:7-alpine} + restart: unless-stopped + ports: + - "${REDIS_BIND_ADDRESS:-127.0.0.1}:${REDIS_PORT:-6379}:6379" + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + server: + image: ${SKILLHUB_SERVER_IMAGE:-ghcr.io/iflytek/skillhub-server}:${SKILLHUB_VERSION:-edge} + restart: unless-stopped + ports: + - "${API_PORT:-8080}:8080" + environment: + SPRING_PROFILES_ACTIVE: docker + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} + REDIS_HOST: redis + REDIS_PORT: 6379 + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} + DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} + SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3} + STORAGE_BASE_PATH: /var/lib/skillhub/storage + SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-} + SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT: ${SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT:-} + SKILLHUB_STORAGE_S3_BUCKET: ${SKILLHUB_STORAGE_S3_BUCKET:-skillhub} + SKILLHUB_STORAGE_S3_ACCESS_KEY: ${SKILLHUB_STORAGE_S3_ACCESS_KEY:-} + SKILLHUB_STORAGE_S3_SECRET_KEY: ${SKILLHUB_STORAGE_S3_SECRET_KEY:-} + SKILLHUB_STORAGE_S3_REGION: ${SKILLHUB_STORAGE_S3_REGION:-us-east-1} + SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false} + SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false} + SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M} + BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false} + BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin} + BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-ChangeMe!2026} + BOOTSTRAP_ADMIN_DISPLAY_NAME: ${BOOTSTRAP_ADMIN_DISPLAY_NAME:-Admin} + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local} + OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} + OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} + volumes: + - skillhub_storage:/var/lib/skillhub/storage + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + web: + image: ${SKILLHUB_WEB_IMAGE:-ghcr.io/iflytek/skillhub-web}:${SKILLHUB_VERSION:-edge} + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + environment: + SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080} + SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-} + SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/nginx-health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + +volumes: + postgres_data: + redis_data: + skillhub_storage: diff --git a/deploy/k8s/backend-deployment.yaml b/deploy/k8s/backend-deployment.yaml new file mode 100644 index 00000000..7c2af196 --- /dev/null +++ b/deploy/k8s/backend-deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skillhub-server + labels: + app.kubernetes.io/name: skillhub-server +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: skillhub-server + template: + metadata: + labels: + app.kubernetes.io/name: skillhub-server + spec: + containers: + - name: server + image: ghcr.io/iflytek/skillhub-server:edge + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: SPRING_PROFILES_ACTIVE + value: docker + - name: SPRING_DATASOURCE_URL + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-url + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-username + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-password + - name: SPRING_DATA_REDIS_HOST + valueFrom: + configMapKeyRef: + name: skillhub-config + key: redis-host + - name: SPRING_DATA_REDIS_PORT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: redis-port + - name: STORAGE_BASE_PATH + valueFrom: + configMapKeyRef: + name: skillhub-config + key: storage-base-path + - name: SESSION_COOKIE_SECURE + value: "true" + - name: OAUTH2_GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-github-client-id + optional: true + - name: OAUTH2_GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-github-client-secret + optional: true + volumeMounts: + - name: skillhub-storage + mountPath: /var/lib/skillhub/storage + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + volumes: + - name: skillhub-storage + persistentVolumeClaim: + claimName: skillhub-storage-pvc diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 00000000..e3988d82 --- /dev/null +++ b/deploy/k8s/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: skillhub-config +data: + redis-host: redis + redis-port: "6379" + storage-base-path: /var/lib/skillhub/storage +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: skillhub-storage-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/deploy/k8s/frontend-deployment.yaml b/deploy/k8s/frontend-deployment.yaml new file mode 100644 index 00000000..05538617 --- /dev/null +++ b/deploy/k8s/frontend-deployment.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skillhub-web + labels: + app.kubernetes.io/name: skillhub-web +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: skillhub-web + template: + metadata: + labels: + app.kubernetes.io/name: skillhub-web + spec: + containers: + - name: web + image: ghcr.io/iflytek/skillhub-web:edge + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: http + readinessProbe: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 00000000..89ff0980 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: skillhub + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 100m +spec: + ingressClassName: nginx + rules: + - host: skills.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: skillhub-server + port: + number: 8080 + - path: / + pathType: Prefix + backend: + service: + name: skillhub-web + port: + number: 80 diff --git a/deploy/k8s/secret.yaml.example b/deploy/k8s/secret.yaml.example new file mode 100644 index 00000000..1aac6fd9 --- /dev/null +++ b/deploy/k8s/secret.yaml.example @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: skillhub-secret +type: Opaque +stringData: + spring-datasource-url: jdbc:postgresql://postgres:5432/skillhub + spring-datasource-username: skillhub + spring-datasource-password: change-me + oauth2-github-client-id: your-client-id + oauth2-github-client-secret: your-client-secret diff --git a/deploy/k8s/services.yaml b/deploy/k8s/services.yaml new file mode 100644 index 00000000..d7a741a4 --- /dev/null +++ b/deploy/k8s/services.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + name: skillhub-server + labels: + app.kubernetes.io/name: skillhub-server +spec: + selector: + app.kubernetes.io/name: skillhub-server + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: skillhub-web + labels: + app.kubernetes.io/name: skillhub-web +spec: + selector: + app.kubernetes.io/name: skillhub-web + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/runtime-mirror-images.txt b/deploy/runtime-mirror-images.txt new file mode 100644 index 00000000..4ad7de89 --- /dev/null +++ b/deploy/runtime-mirror-images.txt @@ -0,0 +1,7 @@ +# source_image target_image +postgres:16-alpine postgres:16-alpine +redis:7-alpine redis:7-alpine +minio/minio:latest minio:latest +prom/prometheus:latest prometheus:latest +grafana/grafana:latest grafana:latest +nginx:alpine nginx:alpine diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 3d5b7374..00000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,86 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: skillhub - POSTGRES_USER: skillhub - POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U skillhub"] - interval: 5s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - - minio: - image: minio/minio:latest - ports: - - "9000:9000" - - "9001:9001" - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 5s - timeout: 5s - retries: 5 - - server: - build: - context: ./server - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - SPRING_PROFILES_ACTIVE: prod - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub - SPRING_DATASOURCE_USERNAME: skillhub - SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - SPRING_DATA_REDIS_HOST: redis - SPRING_DATA_REDIS_PORT: 6379 - OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID} - OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET} - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - minio: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s - - web: - build: - context: ./web - dockerfile: Dockerfile - ports: - - "80:80" - depends_on: - server: - condition: service_healthy - -volumes: - postgres_data: - minio_data: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 00000000..a6c5e2ff --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,76 @@ +# Staging environment: hybrid mode +# - Backend: locally built Docker image +# - Frontend: locally built static files mounted into Nginx +# - Dependencies: reuses docker-compose.yml (postgres, redis, minio) +# +# Usage: make staging +# Do NOT use directly — use the make target which handles build + deps + cleanup. +# NOTE: postgres, redis, minio services are defined in docker-compose.yml. +# When invoked via "docker compose -f docker-compose.yml -f docker-compose.staging.yml", +# Docker Compose merges both files so these services are available by hostname. + +services: + server: + image: skillhub-server:staging + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: docker + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub + SPRING_DATASOURCE_USERNAME: skillhub + SPRING_DATASOURCE_PASSWORD: skillhub_dev + REDIS_HOST: redis + REDIS_PORT: 6379 + SESSION_COOKIE_SECURE: "false" + SKILLHUB_PUBLIC_BASE_URL: "http://localhost" + DEVICE_AUTH_VERIFICATION_URI: "http://localhost/cli/auth" + SKILLHUB_STORAGE_PROVIDER: s3 + STORAGE_BASE_PATH: /var/lib/skillhub/storage + SKILLHUB_STORAGE_S3_ENDPOINT: http://minio:9000 + SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT: http://localhost:9000 + SKILLHUB_STORAGE_S3_BUCKET: skillhub + SKILLHUB_STORAGE_S3_ACCESS_KEY: minioadmin + SKILLHUB_STORAGE_S3_SECRET_KEY: minioadmin + SKILLHUB_STORAGE_S3_REGION: us-east-1 + SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: "true" + SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: "true" + BOOTSTRAP_ADMIN_ENABLED: "true" + BOOTSTRAP_ADMIN_USER_ID: staging-admin + BOOTSTRAP_ADMIN_USERNAME: admin + BOOTSTRAP_ADMIN_PASSWORD: "Admin@staging2026" + BOOTSTRAP_ADMIN_DISPLAY_NAME: Admin + BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local + OAUTH2_GITHUB_CLIENT_ID: local-placeholder + OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + web: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./web/dist:/usr/share/nginx/html:ro + - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro + environment: + SKILLHUB_API_UPSTREAM: http://server:8080 + SKILLHUB_WEB_API_BASE_URL: "" + SKILLHUB_PUBLIC_BASE_URL: "" + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/nginx-health"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s diff --git a/docker-compose.yml b/docker-compose.yml index 63b0e8e8..3da70c7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:16-alpine + image: ${POSTGRES_IMAGE:-postgres:16-alpine} ports: - "5432:5432" environment: @@ -16,7 +16,7 @@ services: retries: 5 redis: - image: redis:7-alpine + image: ${REDIS_IMAGE:-redis:7-alpine} ports: - "6379:6379" healthcheck: @@ -26,7 +26,7 @@ services: retries: 5 minio: - image: minio/minio:latest + image: ${MINIO_IMAGE:-minio/minio:latest} ports: - "9000:9000" - "9001:9001" diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index ce6d4f44..295e46fc 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -50,7 +50,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* - skillhub 自有 CLI 支持两种格式输入,内部统一转换为 namespace 坐标 **Well-known 发现:** -- skillhub 服务端提供 `/.well-known/clawhub.json`,返回 `{ "apiBase": "/api/compat/v1" }` +- skillhub 服务端提供 `/.well-known/clawhub.json`,返回 `{ "apiBase": "/api/v1" }` - ClawHub CLI 通过此机制自动发现兼容层 API 基地址 ## 2. 参考项目取舍 @@ -95,7 +95,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* ## 4. 一期 MVP 功能 核心能力: -- 技能发布(Phase 2 先直达 `PUBLISHED` 跑通主链路,Phase 3 切回“提交 → 审核 → 上线”) +- 技能发布(当前版本采用“提交 → 审核 → 上线”;`SUPER_ADMIN` 保留直发能力) - 技能版本管理(semver + 标签) - 技能浏览、详情、下载(公共技能匿名可访问) - 标签管理(`latest` 系统保留只读 + 自定义标签人工维护) @@ -109,12 +109,15 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* - 创建技能时选择归属空间 审核流程: -- Phase 2:跳过审核,发布链路直达 `PUBLISHED` -- Phase 3 起恢复每版本审核策略 +- 当前版本:普通用户发布后进入审核,审核通过后上线 +- `SUPER_ADMIN` 发布可直达 `PUBLISHED` - 分级审核:团队空间由团队管理员审核,全局空间由平台管理员审核 - 团队技能提升到全局需平台管理员二次审核 - 平台管理员只负责全局空间审核与提升审核,不介入团队空间审核 -- 一期纯人工审核,架构预留自动预检扩展点(`PrePublishValidator`) +- 当前不引入自动审核;`PrePublishValidator` 仅作为未来扩展点保留,默认实现为 `NoOp` +- 撤回审核语义统一为 `PENDING_REVIEW → DRAFT`,不再走删除版本记录 +- skill 生命周期管理读模型统一为 `headlineVersion / publishedVersion / ownerPreviewVersion / resolutionMode` +- `hidden` 是独立治理覆盖层,不属于 skill 容器状态机 认证与权限: - OAuth2 标准登录(一期 GitHub OAuth) @@ -136,7 +139,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* - 评论 → Phase 5 上线,含举报机制 - 自动安全扫描 → Phase 5 上线,接入 `PrePublishValidator` 扩展点 - 举报/标记机制 → Phase 5 上线,配合评论和治理闭环 -- 向量搜索 → Phase 3(搜索演进路线) +- 向量搜索 → 当前进入第一阶段规划,仅做搜索增强,不引入推荐系统 - 在线编辑器 → 暂不规划 - Webhook/事件通知 → Phase 5(预留扩展点) - 技能依赖/兼容性声明 → 暂不规划(预留 `parsed_metadata_json` 字段) diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index ba932eab..ffabb460 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -112,8 +112,12 @@ skillhub/ │ └── Dockerfile # 后端多阶段构建 ├── web/ # React 前端 │ ├── Dockerfile # 前端多阶段构建 -│ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理) +│ ├── nginx.conf.template # Nginx 运行时模板 +│ └── runtime-config.js.template # 前端运行时环境变量模板 ├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO) +├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis) +├── .env.release.example # 单机运行时环境变量模板 +├── .github/workflows/ # GitHub Actions 镜像发布流程 ├── Makefile # 顶层开发编排(dev / dev-all / build) ├── docs/ # 设计文档 └── README.md @@ -123,10 +127,21 @@ skillhub/ ## 8. 部署架构 -同域部署,统一入口: -- `https://skills.example.com/` → 前端静态资源 -- `https://skills.example.com/api/*` → 反向代理到 Spring Boot -- 生产环境通过 Nginx 或网关统一接入 +部署模型收敛为两条路径: + +- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。 +- 交付路径:GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。 +- 发布镜像为多架构 manifest,至少覆盖 `linux/amd64` 与 `linux/arm64`。 + +单机运行时统一入口: +- `http://localhost/` → Web 容器(Nginx) +- `http://localhost/api/*` → Web 容器反向代理到 Spring Boot +- `http://localhost:8080/actuator/health` → 后端健康检查 + +单机运行时默认使用 `docker` profile: +- `docker` 负责容器运行时初始化,例如首个管理员账户 +- 数据库、Redis、对象存储、站点公网地址都通过环境变量注入 +- 生产环境不启用 `local` profile,因此不会暴露 mock 登录旁路 ## 9. 分布式环境要求 @@ -148,3 +163,5 @@ skillhub/ - 缓存/Session:Spring Session + Redis - 数据库迁移:Flyway - 认证:Spring Security OAuth2 Client(一期 GitHub) +- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签 +- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index dddf9414..61a82975 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -64,8 +64,8 @@ | owner_id | varchar(128) | 主要维护人(可转让) | | source_skill_id | bigint | 派生来源(团队技能提升到全局时记录原 skill ID),nullable | | visibility | enum | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | -| status | enum | `ACTIVE` / `HIDDEN` / `ARCHIVED` | -| latest_version_id | bigint | 最新已发布版本(自动跟随,每次发布自动更新) | +| status | enum | `ACTIVE` / `ARCHIVED` | +| latest_version_id | bigint | latest published pointer,仅指向最新 `PUBLISHED` 版本;若不存在已发布版本则可为 `null` | | download_count | bigint | | | star_count | int | | | rating_avg | decimal(3,2) | 平均评分 | @@ -76,6 +76,7 @@ | updated_at | datetime | | - 唯一约束:`(namespace_id, slug)` +- `status` 表示 skill 容器生命周期,不再承载“隐藏”语义。隐藏是独立的治理覆盖层,由 `hidden` / `hidden_at` / `hidden_by` 表达 - `owner_id` 语义为"主要维护人",可转让。权限主轴是 namespace role,不是 owner: - namespace ADMIN 对空间内所有 skill 有完整管理权(归档、版本管理、提升到全局),不受 owner 限制 - owner 作为 MEMBER 时可管理自己创建的 skill(提交审核、编辑草稿) @@ -102,8 +103,14 @@ | published_at | datetime | | | created_at | datetime | | -- `status` 覆盖完整审核生命周期 -- 状态机:`DRAFT → PENDING_REVIEW → PUBLISHED / REJECTED`,`PUBLISHED → YANKED` +- `status` 表示 version 发布生命周期,和 skill 容器状态、review task 状态分离 +- 当前代码下的实际迁移约束: + - 普通用户首次上传/重传新版本后,版本直接进入 `PENDING_REVIEW` + - `SUPER_ADMIN` 直发时可直接进入 `PUBLISHED` + - 审核通过:`PENDING_REVIEW → PUBLISHED` + - 审核拒绝:`PENDING_REVIEW → REJECTED` + - 撤回审核:`PENDING_REVIEW → DRAFT` + - 已发布撤回:`PUBLISHED → YANKED` - 唯一约束:`(skill_id, version)` 防止重复发布 - `YANKED` 状态:已发布后撤回 @@ -112,7 +119,7 @@ | 版本状态 | 版本号处理 | |---------|-----------| | DRAFT | 可删除该版本记录,重新使用同版本号 | -| PENDING_REVIEW | 可撤回到 DRAFT,然后删除 | +| PENDING_REVIEW | 可撤回到 DRAFT | | REJECTED | 可删除该版本记录,重新使用同版本号 | | PUBLISHED | 版本号永久占用,不可复用 | | YANKED | 版本号永久占用,不可复用,版本列表中显示但标记为不可下载 | @@ -144,7 +151,7 @@ | updated_by | varchar(128) | | | updated_at | datetime | | -- `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`,不允许 API 手动移动 +- `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`;其语义严格等价于“最新已发布版本”,不允许 API 手动移动 - 自定义标签(如 `beta`、`stable-2026q1`)允许人工创建和移动 - 唯一约束:`(skill_id, tag_name)` - `target_version_id` 必须指向 `status = PUBLISHED` 的版本,应用层校验 @@ -166,7 +173,7 @@ - 仅用于普通发布审核,"提升到全局"使用独立的 `promotion_request` 表 - `version` 字段用于乐观锁,防止多 Pod 并发审核 -- 业务约束:同一 `skill_version_id` 在 `status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict。撤回(PENDING → 删除 review_task + skill_version 回退到 DRAFT)后才能再次提交 +- 业务约束:同一 `skill_version_id` 在 `status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict。撤回时删除 `PENDING` review_task,并将 `skill_version` 回退到 `DRAFT` - PostgreSQL 并发约束落地:通过唯一索引 `(skill_version_id)` + 软删除标记实现。`review_task` 表增加 `deleted` 字段(bigint, 默认 0),唯一索引改为 `(skill_version_id, deleted)`。撤回时将 `deleted` 设为 `id`(非零值),新提交时 `deleted=0`,利用唯一索引防止并发重复提交。或者采用更简单的方案:撤回时物理删除 review_task 记录,依赖 `INSERT` 的唯一约束 `(skill_version_id)` 防并发。PostgreSQL 还支持 partial unique index 方案:`CREATE UNIQUE INDEX ON review_task (skill_version_id) WHERE status = 'PENDING'`,更优雅地实现"PENDING 状态唯一"约束 ### promotion_request @@ -293,7 +300,7 @@ | 角色 code | 说明 | 典型权限 | |-----------|------|---------| | `SUPER_ADMIN` | 平台超管,拥有所有权限 | 全部 | -| `SKILL_ADMIN` | 技能治理:全局空间审核、提升审核、隐藏/撤回 | `review:approve`, `skill:manage`, `promotion:approve` | +| `SKILL_ADMIN` | 技能治理:全局空间审核、提升审核、隐藏/恢复、撤回已发布版本 | `review:approve`, `skill:manage`, `promotion:approve` | | `USER_ADMIN` | 用户治理:准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN) | `user:manage`, `user:approve` | | `AUDITOR` | 审计只读:查看审计日志 | `audit:read` | @@ -341,7 +348,7 @@ ### skill_search_document -一个 skill 对应一条搜索文档,内容取 `latest_version_id` 对应版本。 +一个 skill 对应一条搜索文档,内容取“最新已发布版本”。实现上可由 `latest_version_id` 作为缓存指针承载,但其语义只能是 latest published pointer。 | 字段 | 类型 | 说明 | |------|------|------| diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 4c2edd13..2f491704 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -16,6 +16,7 @@ ┌─────────────────────────────┐ │ Layer 1: OAuth2 Login │ Spring Security OAuth2 Client │ (一期 GitHub,可扩展) │ 授权码模式 (Authorization Code) +│ Layer 1b: Session Bootstrap│ 显式被动会话引导(默认关闭) └─────────────┬───────────────┘ │ OAuth2User ▼ @@ -141,7 +142,78 @@ AuthenticationSuccessHandler: ② 重定向到前端页面 (可配置的 redirect_uri) ``` -### 3.1 Spring Security 配置要点 +### 3.1 统一 Session 建立约束 + +所有 Web 登录入口都必须通过统一的 `PlatformSessionService` 建立登录态,包括: + +- 本地用户名密码登录 +- OAuth 登录成功回调 +- `POST /api/v1/auth/direct/login` +- `POST /api/v1/auth/session/bootstrap` +- 本地开发态 `MockAuthFilter` + +统一约束如下: + +- 统一写入 `platformPrincipal` +- 统一写入 `SPRING_SECURITY_CONTEXT` +- 统一通过 `HttpSession` 持久化,确保 Spring Session Redis 能无差别接管 +- 交互式登录默认调用 `changeSessionId()`,降低 session fixation 风险 +- 已由 Spring Security 完成认证的入口可以复用现有 `Authentication`,避免重复构造认证结果 + +这意味着未来私有版新增企业 SSO provider 时,只能扩展认证来源本身,不能绕开统一的 session 建立服务直接操作 Session。 + +## 3.3 Session Bootstrap 扩展点 + +为了兼容未来私有部署中的企业 SSO 被动登录,开源版预留显式会话引导协议: + +- 接口:`POST /api/v1/auth/session/bootstrap` +- 用途:前端在同域场景下显式触发一次“读取外部会话并尝试换取 skillhub Session”的流程 +- 默认状态:关闭,开源版不提供任何 `PassiveSessionAuthenticator` 实现 +- 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度 + +扩展接口如下: + +```java +public interface PassiveSessionAuthenticator { + String providerCode(); + Optional authenticate(HttpServletRequest request); +} +``` + +约束如下: + +- `authenticate()` 只负责验证外部被动会话并返回平台登录所需主体 +- 是否允许启用该入口由 `skillhub.auth.session-bootstrap.enabled` 控制,默认 `false` +- 未启用时接口返回 `403` +- 启用但 provider 不受支持时返回 `400` +- 启用但请求中不存在有效外部会话时返回 `401` +- 成功时建立标准 Spring Security Session,并返回与 `/api/v1/auth/me` 一致的用户结构 + +## 3.4 Direct Authentication 扩展点 + +为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象: + +```java +public interface DirectAuthProvider { + String providerCode(); + PlatformPrincipal authenticate(DirectAuthRequest request); +} +``` + +对应公共协议: + +- `POST /api/v1/auth/direct/login` + +约束如下: + +- 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制 +- 关闭时返回 `403` +- provider 不受支持时返回 `400` +- provider 认证失败时沿用 provider 自身的认证异常语义 +- 成功时建立标准 Session,并返回与 `/api/v1/auth/me` 一致的用户结构 +- 现有 `/api/v1/auth/local/login` 保持不变,兼容层只是新增可选入口 + +### 3.5 Spring Security 配置要点 ```java @Configuration @@ -161,14 +233,14 @@ public class SecurityConfig { .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .ignoringRequestMatchers("/api/v1/cli/**")) + .ignoringRequestMatchers("/api/v1/**")) // ... ; } } ``` -### 3.2 OAuth2 Provider 扩展设计 +### 3.6 OAuth2 Provider 扩展设计 一期只实现 GitHub,但架构支持后续扩展: @@ -305,7 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 | 平台角色 | 职责 | |---------|------| | `SUPER_ADMIN` | 全部权限,硬判定短路 | -| `SKILL_ADMIN` | 全局空间审核、提升审核、隐藏/撤回技能 | +| `SKILL_ADMIN` | 全局空间审核、提升审核、隐藏/恢复技能、撤回已发布版本 | | `USER_ADMIN` | 准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN) | | `AUDITOR` | 审计日志只读 | @@ -330,7 +402,8 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 | 审核团队空间技能 | `review:approve` | 该 namespace 的 ADMIN 或 OWNER | | 审核全局空间技能 | `review:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN | | 审核提升申请 | `promotion:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN | -| 隐藏/撤回技能 | `skill:manage` | 持有 SKILL_ADMIN / SUPER_ADMIN | +| 隐藏/恢复技能 | `skill:manage` | 持有 SKILL_ADMIN / SUPER_ADMIN | +| 撤回已发布版本(YANK) | `skill:manage` | 持有 SKILL_ADMIN / SUPER_ADMIN | | 管理用户角色 | `user:manage` | 持有 USER_ADMIN / SUPER_ADMIN | | 审批用户准入 | `user:approve` | 持有 USER_ADMIN / SUPER_ADMIN | | 查看审计日志 | `audit:read` | 持有 AUDITOR / SUPER_ADMIN | @@ -386,7 +459,7 @@ Session 中存储以下字段: - 后端设置 `XSRF-TOKEN` Cookie(`HttpOnly=false`) - 前端从 Cookie 读取 Token,放入请求 Header `X-XSRF-TOKEN` - 后端校验 Header 与 Cookie 是否一致 -- CLI API(`/api/v1/cli/**`)与兼容层(`/api/compat/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie) +- CLI API(`/api/v1/**`)与兼容层(`/api/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie) ## 9. 前端权限控制 @@ -417,6 +490,7 @@ Session 中存储以下字段: 统一约束: - `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。 +- `/api/v1/auth/session/bootstrap` 也必须遵守同一统一响应结构。 - `msg` 必须走 Spring Boot 标准 `MessageSource` i18n 机制。 - locale 必须通过请求上下文自动获取,不在 controller 中显式传递。 - 认证失败返回 `401`,但 JSON 外层结构仍保持一致,例如 `{"code":401,"msg":"需要先登录","data":null,...}`。 @@ -527,7 +601,7 @@ window.location.href = '/oauth2/authorization/github' | `POST /api/v1/skills/{ns}/{slug}/star` | 已登录 | Session/Token | | `POST /api/v1/skills/{ns}/{slug}/rating` | 已登录 | Session/Token | | `POST .../versions/{ver}/submit-review` | namespace MEMBER 以上 | `namespace_member.role` | -| `POST .../versions/{ver}/withdraw-review` | 提交人本人 或 namespace ADMIN | `review_task.submitted_by` 或 `namespace_member.role` | +| `POST .../versions/{ver}/withdraw-review` | 提交人本人 | `review_task.submitted_by` | | `PUT /api/v1/skills/{ns}/{slug}/tags/{tag}` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | | `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | | `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 DRAFT/REJECTED) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | @@ -536,8 +610,8 @@ window.location.href = '/oauth2/authorization/github' | 接口 | 所需凭证 | 额外判定 | |------|---------|---------| -| `GET /api/v1/cli/whoami` | 任意有效 Bearer Token | 无 | -| `POST /api/v1/cli/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 | +| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | +| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 | ### 10.4 Admin API @@ -566,8 +640,8 @@ window.location.href = '/oauth2/authorization/github' | 接口 | 所需凭证 | 额外判定 | |------|---------|---------| -| `GET /api/compat/v1/whoami` | 任意有效 Bearer Token | 无 | -| `GET /api/compat/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` | -| `GET /api/compat/v1/resolve` | 可选(匿名限 PUBLIC) | visibility | -| `GET /api/compat/v1/download/{slug}/{version}` | 可选(匿名限 PUBLIC) | visibility | -| `POST /api/compat/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上(namespace 由 canonical slug 解析) | +| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | +| `GET /api/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` | +| `GET /api/v1/resolve` | 可选(匿名限 PUBLIC) | visibility | +| `GET /api/v1/download/{slug}/{version}` | 可选(匿名限 PUBLIC) | visibility | +| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上(namespace 由 canonical slug 解析) | diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 5dfe3b90..4b38a3eb 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -57,7 +57,7 @@ WHERE (visibility = 'PUBLIC') ## 3 搜索文档表 skill_search_document -一个 skill 对应一条搜索文档,内容取 `latest_version_id` 对应版本。版本发布时自动更新该条文档。 +一个 skill 对应一条搜索文档,但文档内容的来源语义应严格收敛为“当前最新已发布版本”。实现上仍可由 `latest_version_id` 作为缓存指针承载,但它只允许指向 `PUBLISHED` 版本;搜索层不能再把它当作泛化的“当前版本”。 | 字段 | 类型 | 说明 | |------|------|------| @@ -80,14 +80,15 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列, ## 4 索引写入时机 以下场景触发搜索文档更新(upsert by skill_id): -- 审核通过(`PENDING_REVIEW → PUBLISHED`):`latest_version_id` 自动更新,用新版本内容更新搜索文档 +- 审核通过(`PENDING_REVIEW → PUBLISHED`):重算“最新已发布版本”指针,并用该发布版本内容更新搜索文档 +- 已发布版本被撤回(`PUBLISHED → YANKED`):重算“最新已发布版本”指针;若不存在任何已发布版本,则移除搜索文档 - 技能状态变更(隐藏/归档/恢复):更新搜索文档的 status 字段 ## 5 搜索演进路线 ### 5.1 一期数据建模约束 -一期"每个 skill 一条搜索文档、内容永远取 latest_version_id"是有意的简化。这个模型在以下场景下会不够用: +一期“每个 skill 一条搜索文档、内容永远取最新已发布版本”是有意的简化。当前实现仍使用 `latest_version_id` 作为持久化指针,但这里的语义已经收敛为 latest published pointer。这个模型在以下场景下会不够用: - 版本级检索(搜索某个旧版本的内容) - 自定义标签/通道检索(搜索 `@beta` 标签指向的版本内容) @@ -96,7 +97,7 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列, 这些场景不是简单换 provider 能解决的,需要改表结构和索引写入逻辑。 **一期搜索能力边界(产品限制):** -- 搜索只基于 `latest_version_id` 对应版本的内容 +- 搜索只基于“最新已发布版本”的内容 - 不支持按 version 或 tag 搜索内容 - 搜索结果不区分 channel(`beta`、`stable` 等标签通道) - 用户通过 tag 安装的技能内容可能与搜索结果展示的内容不一致(搜索展示 latest,安装的是 tag 指向的版本) @@ -106,11 +107,18 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列, | 阶段 | 实现 | 索引粒度 | 切换方式 | |------|------|---------|---------| -| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条(latest_version_id) | 默认 | +| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条(latest published) | 默认 | +| 一点五期 | PostgreSQL Full-Text + 语义向量重排 | 每 skill 一条(latest published) | 配置 `skillhub.search.semantic.enabled=true` | | 二期 | ES / OpenSearch | 每 skill_version 一条 + skill 聚合文档 | 配置 `search.provider=elasticsearch` | | 三期 | 向量检索 | 每 skill_version 多条(chunk 级) | 配置 `search.provider=vector` | | 四期 | 混合排序 | 关键词 + 向量混合 | 配置 `search.provider=hybrid` | +当前代码实现已落在“一点五期”: +- 仍然使用 PostgreSQL 全文搜索作为主召回 +- 搜索文档表新增 `semantic_vector` 缓存字段 +- relevance 排序下,对全文候选集追加语义向量重排 +- 语义向量不可用时自动降级为现有全文相关度排序 + ### 5.3 SPI 演进策略 一期 SPI 接口(`SearchIndexService` / `SearchQueryService`)的入参是 `SkillSearchDocument`(skill 粒度)。二期切换到 ES 时: diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 4b1783b4..c5f53d03 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -6,7 +6,7 @@ > **设计决策**:一期暂不考虑异步发布(uploadId、publishId、状态轮询、异步转正等)。一期技能包为文本资源包,体积有限(上限 10MB),同步处理足以满足需求。如后续引入大文件或复杂校验流程,再考虑异步模型。 -### 1.1 Phase 2 发布流程基线 +### 1.1 当前发布流程基线 ``` 用户提交发布 @@ -29,11 +29,11 @@ ▼ ④ 持久化数据 - 创建或关联 skill 记录(首次发布时创建 skill) - - 创建 skill_version(status=PUBLISHED) + - 创建 skill_version(普通用户进入 `PENDING_REVIEW`,`SUPER_ADMIN` 直达 `PUBLISHED`) - 创建 skill_file 记录 - 解析 SKILL.md frontmatter → parsed_metadata_json - 生成 manifest_json - - 更新 skill.latest_version_id + - 直发场景更新 skill.latest_version_id │ ▼ ⑤ 同步写入审计日志 @@ -42,17 +42,32 @@ ⑥ 异步触发搜索索引写入 ``` -Phase 2 的目标是先跑通上传、存储、发布、查询、下载完整链路,因此不经过审核,发布结果直接进入 `PUBLISHED`。 +当前版本采用审核流,不再区分“Phase 2 直发”与“Phase 3 恢复审核”两套现实实现: -### 1.2 Phase 3 迁移后的发布流程 +- 普通用户发布请求创建 `skill_version(status=PENDING_REVIEW)` +- 同步创建 `review_task(status=PENDING)` +- 审核通过后转为 `PUBLISHED` +- 审核拒绝后转为 `REJECTED` +- 撤回审核时删除 `PENDING review_task`,并将 `skill_version` 回退到 `DRAFT` +- 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员 +- 上述例外必须对 Web、`/api/v1/publish`、`/api/v1/publish` 保持一致 +- 若重传新版本时发现旧的 `PENDING_REVIEW` 版本,旧版本会被自动降回 `DRAFT`,再创建新的待审版本 -Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DRAFT → 提交审核 → 人工审核 → 发布”: +### 1.2 生命周期读模型 -- 发布请求先创建 `skill_version(status=DRAFT)` -- 提交审核后转为 `PENDING_REVIEW` -- 创建 `review_task(status=PENDING)` -- 审核通过后才转为 `PUBLISHED` -- 审核拒绝后转为 `REJECTED` +当前代码中的 skill 生命周期展示与操作判断,不再依赖旧的 `latestVersionStatus`、`viewingVersionStatus` 一类拼装字段,而统一基于以下 projection: + +- `headlineVersion`:当前详情页/我的技能列表主展示版本 +- `publishedVersion`:当前最新可公开分发的已发布版本 +- `ownerPreviewVersion`:owner 或 namespace 管理者可见的待审核版本 +- `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE` + +业务规则: + +- 公开入口只认 `publishedVersion` +- owner 进入详情页时,如果没有可用 `publishedVersion`,才允许 `headlineVersion = ownerPreviewVersion` +- 推广到全局、安装命令、公开下载都只能绑定到 `publishedVersion` +- `hidden` 是独立治理覆盖层,不属于 skill 生命周期状态机 ### 对象存储写入策略 @@ -65,7 +80,7 @@ Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DR ### CLI publish 请求规范 ``` -POST /api/v1/cli/publish +POST /api/v1/publish Content-Type: multipart/form-data Parts: - file: zip 包(必需) @@ -74,11 +89,11 @@ Parts: 一期同步响应:服务端同步完成上传、校验、存储、持久化,返回 `200 OK` + skill_version 信息。 -Phase 2 CLI 默认行为:上传 → 直接发布为 `PUBLISHED`。 -Phase 3 CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核。 -Web 端可保留“发布后再提交审核”的两段式体验,但这属于 Phase 3 能力。 +当前 CLI 默认行为:上传 → 进入审核。 +如果调用方持有 `SUPER_ADMIN`,则直接发布为 `PUBLISHED`。 +Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确的审核提示。 -`/api/v1/cli/publish` 响应: +`/api/v1/publish` 响应: ```json { @@ -124,6 +139,11 @@ Web 端可保留“发布后再提交审核”的两段式体验,但这属于 - 原团队 skill 可继续独立迭代 - 两者版本不自动同步,如需同步由 owner 手动操作 +提升流程当前严格绑定已发布版本: + +- promotion request 的 `source_version_id` 必须指向 `publishedVersion.id` +- 不允许直接提升 `ownerPreviewVersion` + ## 3 下载流程 ``` @@ -196,7 +216,7 @@ Web 端可保留“发布后再提交审核”的两段式体验,但这属于 | `SkillDownloadedEvent` | 下载完成 | 下载计数 | | `SkillStarredEvent` | 收藏/取消 | 收藏计数 | | `SkillRatedEvent` | 评分提交 | 评分重算 | -| `ReviewCompletedEvent` | 审核完成 | 通知提交者(一期可选) | +| `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) | | `SkillPromotedEvent` | 提升到全局 | 搜索索引写入(新 skill) | 一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 7c2a8ed5..ee3a49e2 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -76,7 +76,7 @@ | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 版本详情 | | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/files` | 文件清单 | | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/file?path=...` | 读取单个文件(query param 避免路径中 / 的解析问题) | -| GET | `/api/v1/skills/{namespace}/{slug}/download` | 下载默认安装版本(latest_version_id 指向的版本) | +| GET | `/api/v1/skills/{namespace}/{slug}/download` | 下载默认安装版本(最新已发布版本) | | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本包 | | GET | `/api/v1/skills/{namespace}/{slug}/resolve` | 解析技能版本(支持 query param: `version`、`tag`、`hash`) | | GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) | @@ -95,7 +95,7 @@ Public API 的可见性规则: - `parsedMetadataJson`:`SKILL.md` frontmatter 的完整 JSON 序列化结果 - `manifestJson`:版本文件清单摘要 JSON -## 7.2 Auth API(OAuth2 登录相关) +## 7.2 Auth API(登录与会话相关) | 方法 | 路径 | 说明 | |------|------|------| @@ -104,6 +104,9 @@ Public API 的可见性规则: | GET | `/api/v1/auth/me` | 当前用户信息(未登录返回 401) | | POST | `/api/v1/auth/logout` | 登出(清除 Session) | | GET | `/api/v1/auth/providers` | 可用的 OAuth Provider 列表(前端渲染登录按钮用) | +| GET | `/api/v1/auth/methods` | 统一登录方式目录(密码/OAuth/direct/bootstrap 元数据) | +| POST | `/api/v1/auth/direct/login` | 显式走直连认证 provider 的兼容登录入口(默认关闭) | +| POST | `/api/v1/auth/session/bootstrap` | 显式尝试用外部被动会话换取 skillhub Session(默认关闭) | `/api/v1/auth/providers` 响应示例: @@ -121,6 +124,74 @@ Public API 的可见性规则: 前端根据此接口动态渲染登录按钮,新增 Provider 无需改前端代码。 +`/api/v1/auth/methods` 返回统一登录方式目录。典型项包括: + +- `PASSWORD`:现有本地账号密码登录 +- `OAUTH_REDIRECT`:OAuth 跳转登录 +- `DIRECT_PASSWORD`:默认关闭的直连认证兼容入口 +- `SESSION_BOOTSTRAP`:默认关闭的被动会话引导入口 + +示例: + +```json +{ + "code": 0, + "msg": "获取成功", + "data": [ + { + "id": "local-password", + "methodType": "PASSWORD", + "provider": "local", + "displayName": "Local Account", + "actionUrl": "/api/v1/auth/local/login" + }, + { + "id": "oauth-github", + "methodType": "OAUTH_REDIRECT", + "provider": "github", + "displayName": "GitHub", + "actionUrl": "/oauth2/authorization/github" + } + ], + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" +} +``` + +`/api/v1/auth/session/bootstrap` 请求示例: + +```json +{ + "provider": "private-sso" +} +``` + +`/api/v1/auth/session/bootstrap` 协议约束: + +- 开源版默认关闭,需显式开启 `skillhub.auth.session-bootstrap.enabled=true` +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 外部会话不存在或校验失败时返回 `401` +- 成功时返回与 `/api/v1/auth/me` 相同的用户结构,并建立标准 Session + +`/api/v1/auth/direct/login` 请求示例: + +```json +{ + "provider": "private-sso", + "username": "alice", + "password": "secret" +} +``` + +`/api/v1/auth/direct/login` 协议约束: + +- 开源版默认关闭,需显式开启 `skillhub.auth.direct.enabled=true` +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 成功时返回与 `/api/v1/auth/me` 相同的用户结构,并建立标准 Session +- `/api/v1/auth/local/login` 继续保留,作为现有本地账号入口 + ## 7.3 Authenticated API(需登录) | 方法 | 路径 | 说明 | @@ -135,7 +206,7 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 DRAFT 版本提交审核 | +| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 `DRAFT` 版本再次提交审核(当前主要用于撤回后重提) | | POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | 撤回提审(PENDING_REVIEW → DRAFT,同时删除关联的 PENDING review_task) | | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/draft` | 查看草稿详情(owner 或 namespace ADMIN 以上) | @@ -155,6 +226,20 @@ Public API 的可见性规则: | POST | `/api/v1/skills/{namespace}/{slug}/unarchive` | 恢复归档(namespace ADMIN 或 owner) | | DELETE | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 删除 DRAFT/REJECTED 版本 | +当前代码中的 skill 生命周期读模型不再依赖 `latestVersionStatus` / `viewingVersionStatus` 一类拼装字段,而统一使用以下 projection: + +- `headlineVersion`:当前页面应展示的主版本 +- `publishedVersion`:当前最新可分发的已发布版本 +- `ownerPreviewVersion`:owner / namespace 管理者可见的待审核预览版本 +- `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE` + +其中: + +- 公开详情、公开安装、公开搜索一律只认 `publishedVersion` +- owner 详情页在没有可展示发布版本时,才允许 `headlineVersion` 落到 `ownerPreviewVersion` +- 推广到全局一律使用 `publishedVersion.id` +- `hidden` 是独立治理覆盖层,不属于生命周期状态机 + 发布成功响应中的 `data` 至少包含以下字段: - `skillId` @@ -165,6 +250,13 @@ Public API 的可见性规则: - `fileCount` - `totalSize` +发布状态约束: + +- 普通用户发布成功后,`status` 为 `PENDING_REVIEW` +- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/publish`、`/api/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员 +- 当前版本保持该审核策略,不再提供“全员直发”的运行模式 +- 撤回审核不会删除版本记录,而是 `PENDING_REVIEW → DRAFT` + ## 7.4 Token API(需登录) | 方法 | 路径 | 说明 | @@ -177,10 +269,10 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/v1/cli/whoami` | 当前 Bearer Token 对应的用户信息 | -| POST | `/api/v1/cli/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流) | -| GET | `/api/v1/cli/resolve/{namespace}/{slug}` | 解析版本 | -| GET | `/api/v1/cli/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 | +| GET | `/api/v1/whoami` | 当前 Bearer Token 对应的用户信息 | +| POST | `/api/v1/publish` | 发布技能包(普通用户进入审核;`SUPER_ADMIN` 始终直发) | +| GET | `/api/v1/resolve/{namespace}/{slug}` | 解析版本 | +| GET | `/api/v1/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 | ### ClawHub CLI 协议兼容层 @@ -190,7 +282,7 @@ Public API 的可见性规则: - 范围:一期聚焦覆盖 ClawHub CLI 所依赖的核心接口:查询、版本解析、下载、发布、whoami - 要求:兼容层优先保持 ClawHub CLI 既有请求/响应语义;若内部领域模型不同,通过 adapter 层完成协议转换,而不是要求客户端适配 skillhub 私有协议 - 要求:兼容层纳入 OpenAPI 或独立兼容协议文档,并作为正式对外契约维护 -- 要求:兼容层与 skillhub 自有 `/api/v1/cli/**` 并存,二者共享同一套权限、审计、限流与领域服务 +- 要求:兼容层与 skillhub 自有 `/api/v1/**` 并存,二者共享同一套权限、审计、限流与领域服务 - 非目标:前端页面不直接依赖兼容层;兼容层用于服务已有 ClawHub CLI 和相关自动化脚本 兼容层最少需要覆盖的能力类别: @@ -259,14 +351,15 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: `latest` 自动跟随最新已发布版本,不可手动移动。 - `skill.latest_version_id`:每次审核通过自动更新,始终指向最新 PUBLISHED 版本 +- `yank` 当前最新已发布版本时,需要同步重算 `latest_version_id` 指向下一个最新的 `PUBLISHED` 版本;若不存在则允许为 `null` - `latest` 标签:系统保留,只读,自动与 `latest_version_id` 同步 - 自定义标签(如 `beta`、`stable-2026q1`):允许人工创建和移动,用于固定安装通道 | 场景 | 使用字段 | 说明 | |------|---------|------| -| 搜索索引内容 | `latest_version_id` | 搜索文档取最新已发布版本内容 | -| `/download`(不带版本号) | `latest_version_id` | 下载最新已发布版本 | -| CLI `install @team/skill` | `latest_version_id` | 等同于 `@latest` | +| 搜索索引内容 | `publishedVersion` / `latest_version_id` | 外部协议仍叫 latest,但内部语义必须等价于最新已发布版本 | +| `/download`(不带版本号) | `publishedVersion` / `latest_version_id` | 下载最新已发布版本 | +| CLI `install @team/skill` | `publishedVersion` / `latest_version_id` | 等同于 `@latest` | | CLI `install @team/skill@beta` | `skill_tag` 查询 | 自定义标签指向的版本 | ## 7.9 Resolve 接口说明 @@ -284,7 +377,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: 2. 仅传 `version`:精确匹配版本号 3. 仅传 `tag`:查询 `skill_tag` 表获取 `target_version_id` 4. 仅传 `hash`:遍历已发布版本,比对 fingerprint -5. 均不传:返回 `latest_version_id` 指向的版本 +5. 均不传:返回最新已发布版本;实现上可由 `latest_version_id` 或等价 published projection 解析 响应: @@ -310,7 +403,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: ## 7.10 ClawHub CLI 兼容层 API -兼容层 API 基地址为 `/api/compat/v1`,通过 `/.well-known/clawhub.json` 发现。兼容层使用 canonical slug(双连字符映射规则,详见 `00-product-direction.md` 1.1 节)。 +兼容层 API 基地址为 `/api/v1`,通过 `/.well-known/clawhub.json` 发现。兼容层使用 canonical slug(双连字符映射规则,详见 `00-product-direction.md` 1.1 节)。 认证方式:`Authorization: Bearer `。Bearer Token 可来自 CLI Device Flow 或平台 API Token。 @@ -321,7 +414,7 @@ GET /.well-known/clawhub.json 响应: { - "apiBase": "/api/compat/v1" + "apiBase": "/api/v1" } ``` @@ -329,15 +422,15 @@ GET /.well-known/clawhub.json | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/compat/v1/whoami` | 当前用户信息 | -| GET | `/api/compat/v1/search` | 搜索技能 | -| GET | `/api/compat/v1/resolve` | 通过 slug + version 解析版本 | -| GET | `/api/compat/v1/download/{slug}/{version}` | 下载技能 zip 包 | -| POST | `/api/compat/v1/publish` | 发布技能(multipart/form-data) | +| GET | `/api/v1/whoami` | 当前用户信息 | +| GET | `/api/v1/search` | 搜索技能 | +| GET | `/api/v1/resolve` | 通过 slug + version 解析版本 | +| GET | `/api/v1/download/{slug}/{version}` | 下载技能 zip 包 | +| POST | `/api/v1/publish` | 发布技能(multipart/form-data,`SUPER_ADMIN` 直发) | ### 兼容层请求/响应格式 -**GET `/api/compat/v1/whoami`** +**GET `/api/v1/whoami`** ```json { @@ -347,7 +440,7 @@ GET /.well-known/clawhub.json } ``` -**GET `/api/compat/v1/search?q={keyword}&page={page}&limit={limit}`** +**GET `/api/v1/search?q={keyword}&page={page}&limit={limit}`** ```json { @@ -372,21 +465,21 @@ GET /.well-known/clawhub.json 注意:兼容层返回的 `slug` 为 canonical slug 格式(全局空间直接返回 skill slug,团队空间返回 `namespace--skill`)。 -**GET `/api/compat/v1/resolve?slug={slug}&version={version}`** +**GET `/api/v1/resolve?slug={slug}&version={version}`** ```json { "slug": "my-skill", "version": "1.2.0", - "downloadUrl": "/api/compat/v1/download/my-skill/1.2.0" + "downloadUrl": "/api/v1/download/my-skill/1.2.0" } ``` -**GET `/api/compat/v1/download/{slug}/{version}`** +**GET `/api/v1/download/{slug}/{version}`** 返回指定版本的 zip 文件流。默认版本解析由 `resolve` 接口负责。 -**POST `/api/compat/v1/publish`** +**POST `/api/v1/publish`** ``` Content-Type: multipart/form-data @@ -444,7 +537,7 @@ Parts: - 版本策略:URL path 版本 `/api/v1/` - 幂等性:写操作通过 `X-Request-Id` + Redis 去重(TTL 24h) -### Compatibility API(`/api/compat/v1/*`) +### Compatibility API(`/api/v1/*`) - 响应格式完全遵循 ClawHub 协议,不套统一响应包裹 - 错误响应遵循 ClawHub 格式:`{ error: string, message: string }` diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 5f144449..958c4bb6 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -116,7 +116,7 @@ CLI 安装后在本地写入 `.astron/metadata.json`: skillhub 自有 CLI 支持完整 namespace 坐标: ``` -install @team/my-skill → 最新已发布版本(latest_version_id) +install @team/my-skill → 最新已发布版本(实现上通常由 `latest_version_id` / published pointer 解析) install @team/my-skill@1.2.0 → 精确版本 install @team/my-skill@latest → 等同于不带版本号(系统保留标签,只读) install @team/my-skill@beta → beta 标签(自定义标签) diff --git a/docs/08-frontend-architecture.md b/docs/08-frontend-architecture.md index 6b243f82..681d24bc 100644 --- a/docs/08-frontend-architecture.md +++ b/docs/08-frontend-architecture.md @@ -38,7 +38,7 @@ | 页面 | 路径 | 说明 | |------|------|------| -| 我的技能 | `/dashboard/skills` | 我发布的技能 + 审核状态 | +| 我的技能 | `/dashboard/skills` | 我发布的技能 + 统一生命周期状态 | | 发布技能 | `/dashboard/publish` | zip 上传 + 预览 + 提交审核 | | 我的收藏 | `/dashboard/stars` | 收藏列表 | | Token 管理 | `/dashboard/tokens` | 创建/查看/吊销 | @@ -57,7 +57,7 @@ |------|------|---------|------| | 审核中心 | `/admin/reviews` | SKILL_ADMIN | 全局待审核列表 | | 提升审核 | `/admin/promotions` | SKILL_ADMIN | 提升到全局的申请列表 | -| 技能管理 | `/admin/skills` | SKILL_ADMIN | 隐藏/恢复/撤回 | +| 技能管理 | `/admin/skills` | SKILL_ADMIN | 隐藏/恢复技能、撤回已发布版本 | | 用户管理 | `/admin/users` | USER_ADMIN | 用户列表、角色分配、准入审批、封禁/解封 | | 审计日志 | `/admin/audit-logs` | AUDITOR | 操作日志查询 | | 命名空间管理 | `/admin/namespaces` | SUPER_ADMIN | 创建/归档/冻结 | @@ -70,6 +70,21 @@ SUPER_ADMIN 可访问所有管理页面。路由守卫检查用户是否持有 - Dashboard / Admin:顶部导航 + 左侧边栏,管理效率优先 - 响应式:移动端侧边栏收起为抽屉 +## 3.1 生命周期展示模型 + +前端不再从 `status + hidden + latestVersionStatus + viewingVersionStatus` 拼装 skill 生命周期,而统一消费后端返回的 projection: + +- `headlineVersion`:当前页面主展示版本 +- `publishedVersion`:当前最新已发布版本 +- `ownerPreviewVersion`:owner / namespace 管理者可见的待审核版本 +- `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE` + +约束: + +- 详情页和“我的技能”列表统一以 `headlineVersion` 作为主展示版本 +- 安装、下载、promotion 等公开分发相关操作只允许绑定 `publishedVersion` +- `hidden` 是独立治理覆盖层,不属于版本生命周期状态机 + ## 4 登录与鉴权 ### 4.1 OAuth2 登录流程(前端视角) @@ -107,7 +122,39 @@ window.location.href = "/oauth2/authorization/github" 2. 跳转到对应的 `authorizationUrl` 3. 回调后通过 `/api/v1/auth/me` 检测登录态 -### 4.2 登录态检测 +### 4.2 预留的被动会话引导 + +为未来私有部署下的企业 SSO 兼容,前端可在登录页或应用初始化阶段显式调用: + +- `POST /api/v1/auth/session/bootstrap` + +该接口在开源版默认关闭;私有版启用后,前端可在检测到用户未登录时主动调用一次,以尝试将外部 SSO Cookie 换成 skillhub Session。该流程必须保持显式触发,不默认依赖全局透明拦截器。 + +前端兼容接入层约束如下: + +- 默认不启用,运行时配置不打开时,登录页和全局行为与开源版完全一致 +- 账号密码登录兼容层与被动会话兼容层相互独立,可单独启用 +- 启用后,登录页会出现一个“企业 SSO”兼容入口 +- 启用密码兼容层后,登录页账号密码表单会改为调用通用直连认证接口 +- 前端应优先消费 `/api/v1/auth/methods` 作为统一登录方式目录;`/api/v1/auth/providers` 仅保留兼容 +- 可选自动尝试,但仍限定在登录页内执行,不在全站每次匿名访问时自动探测 +- bootstrap 失败时应静默回退到现有本地登录和 OAuth 登录,不打断正常流程 + +前端运行时配置项: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + +推荐策略: + +- 私有版密码直连:`auth_direct_enabled=true`,`auth_direct_provider=private-sso` +- 私有版初期:`enabled=true`,`provider=private-sso`,`auto=false` +- 验证稳定后:再评估是否切到 `auto=true` + +### 4.3 登录态检测 ``` 页面加载 → GET /api/v1/auth/me diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 21f010ee..f5798cec 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -1,350 +1,239 @@ # skillhub 部署架构与运维 -## 1 K8s 部署拓扑 - -``` - ┌─────────────┐ - │ Ingress │ - │ (Nginx) │ - └──────┬──────┘ - │ - ┌────────────┴────────────┐ - │ /api/* │ /* - ▼ ▼ - ┌──────────────────┐ ┌──────────────────┐ - │ Spring Boot │ │ Nginx / CDN │ - │ replicas: 2+ │ │ 静态资源 │ - └────────┬─────────┘ └──────────────────┘ - │ - ┌────────┴──────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌────────┐ ┌────────┐ ┌──────────────┐ -│ PostgreSQL│ │ Redis │ │ S3 / MinIO │ -│ (主从) │ │ │ │ │ -└────────┘ └────────┘ └──────────────┘ -``` - -## 2 服务配置 - -- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3 -- 健康检查:`/actuator/health`(liveness + readiness 分离) -- 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s` -- JVM:`-XX:MaxRAMPercentage=75.0` - -## 3 环境 Profile - -| Profile | 用途 | 特点 | +## 1 运行模型 + +当前仓库只保留两种运行方式: + +- 开发环境:`make dev-all` + - 前端和后端运行在宿主机 + - `docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO +- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d` + - 前端和后端都运行在容器内 +- 使用 GitHub Actions 发布到 GHCR 的镜像 +- 默认发布 `linux/amd64` 与 `linux/arm64` 多架构镜像 + - PostgreSQL、Redis 与应用容器一起通过 Compose 启动 + +不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`。 + +## 2 单机交付拓扑 + +``` +┌──────────────┐ +│ Browser / CLI│ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Web/Nginx │ published image +└──────┬───────┘ + │ /api/* + ▼ +┌──────────────┐ +│ Spring Boot │ published image +└───┬────┬─────┘ + │ │ + ▼ ▼ + PostgreSQL Redis +``` + +说明: +- Web 容器提供静态资源,并将 `/api/*`、`/oauth2/*`、`/.well-known/*` 反代到后端 +- 后端默认运行 `docker` profile,不再启用本地 mock 登录 +- PostgreSQL / Redis 默认只绑定 `127.0.0.1` +- 对象存储推荐使用外部 S3 / OSS,通过环境变量注入 + +## 3 Profile 约定 + +| Profile | 用途 | 说明 | |---------|------|------| -| `local` | 本地开发 | Docker Compose 一键启动(PostgreSQL/Redis/MinIO),Mock OAuth(见下方说明) | -| `dev` | 开发环境 | 共享基础设施,GitHub OAuth 测试应用 | -| `staging` | 预发布 | 与生产同构 | -| `prod` | 生产 | 多 Pod,完整基础设施 | - -### 本地开发 Mock 登录 - -`local` profile 下提供两种开发登录方式: - -1. **MockAuthFilter**(默认):通过 `X-Mock-User-Id` Header 模拟登录,自动创建 Session,无需真实 OAuth 流程 -2. **GitHub OAuth 测试应用**:配置 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` 后可走真实 OAuth 流程(GitHub 支持 `http://localhost` 回调) - -MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。 - -### Docker Compose 说明 - -当前推荐的本地启动入口是 `make dev-all`。Docker Compose 在当前项目里主要承担本地依赖服务启动。 - -常用命令: - -```bash -make dev-all -make dev-all-down -make dev-all-reset -``` - -#### docker-compose.yml — 本地开发(仅依赖服务) - -本地开发时前后端在宿主机运行,Docker Compose 只拉起依赖服务: - -```yaml -# docker-compose.yml(项目根目录) -services: - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: skillhub - POSTGRES_USER: skillhub - POSTGRES_PASSWORD: skillhub_dev - volumes: - - postgres_data:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - minio: - image: minio/minio:latest - ports: - - "9000:9000" - - "9001:9001" # MinIO Console - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - -volumes: - postgres_data: - minio_data: -``` +| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | +| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不会自动打开首登管理员 | -生产环境文档不再提供 Compose 一键部署入口。当前仓库只保留本地开发所需的 `docker-compose.yml`,正式部署以镜像构建 + K8s 编排为准。 - -#### 前后端 Dockerfile - -后端 Dockerfile(`server/Dockerfile`): -```dockerfile -FROM maven:3.9-eclipse-temurin-21 AS build -WORKDIR /app -COPY pom.xml . -COPY skillhub-app/pom.xml skillhub-app/ -COPY skillhub-domain/pom.xml skillhub-domain/ -COPY skillhub-auth/pom.xml skillhub-auth/ -COPY skillhub-search/pom.xml skillhub-search/ -COPY skillhub-storage/pom.xml skillhub-storage/ -COPY skillhub-infra/pom.xml skillhub-infra/ -RUN mvn dependency:go-offline -B -COPY . . -RUN mvn package -DskipTests -B - -FROM eclipse-temurin:21-jre-alpine -WORKDIR /app -COPY --from=build /app/skillhub-app/target/*.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] -``` - -前端 Dockerfile(`web/Dockerfile`): -```dockerfile -FROM node:20-alpine AS build -WORKDIR /app -RUN corepack enable -COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm build - -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 -``` +单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: -前端 Nginx 配置(`web/nginx.conf`): -```nginx -server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - # SPA 路由回退 - location / { - try_files $uri $uri/ /index.html; - } - - # API 反向代理到后端 - location /api/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # OAuth2 回调反向代理 - location /oauth2/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - location /login/oauth2/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # Well-known 发现端点 - location /.well-known/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - } -} -``` +- 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力 +- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile,且默认关闭 +- 数据库、Redis、OSS、站点公网地址全部改为环境变量优先 -### Spring Boot 配置文件分层 +如需启用首登管理员,来源于以下环境变量: -``` -server/skillhub-app/src/main/resources/ -├── application.yml # 公共配置(所有 profile 共享) -├── application-local.yml # 本地开发(Docker Compose 服务地址) -├── application-dev.yml # 开发环境 -├── application-staging.yml # 预发布 -└── application-prod.yml # 生产 -``` +- `BOOTSTRAP_ADMIN_ENABLED=true` +- `BOOTSTRAP_ADMIN_USERNAME` +- `BOOTSTRAP_ADMIN_PASSWORD` -`application.yml`(公共配置): -```yaml -spring: - application: - name: skillhub - jpa: - open-in-view: false - hibernate: - ddl-auto: validate # 由 Flyway 管理 schema,Hibernate 仅校验 - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - locations: classpath:db/migration - -server: - shutdown: graceful - -spring.lifecycle.timeout-per-shutdown-phase: 30s -``` +建议: -`application-local.yml`(本地开发,对应 Docker Compose): -```yaml -spring: - datasource: - url: jdbc:postgresql://localhost:5432/skillhub - username: skillhub - password: skillhub_dev - data: - redis: - host: localhost - port: 6379 - jpa: - show-sql: true - -skillhub: - storage: - type: s3 - endpoint: http://localhost:9000 - access-key: minioadmin - secret-key: minioadmin - bucket: skillhub - region: us-east-1 - access-policy: - mode: OPEN # 本地开发默认开放准入 -``` +- 默认保持 `BOOTSTRAP_ADMIN_ENABLED=false` +- 完成首次登录后立即修改管理员密码 +- 如果已有外部身份源,通常不需要启用 bootstrap admin +- `SKILLHUB_PUBLIC_BASE_URL` 应配置为最终 HTTPS 域名,避免 OAuth / Cookie / 设备码链接异常 -`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入): -```yaml -spring: - datasource: - url: ${DATABASE_URL} - username: ${DATABASE_USERNAME} - password: ${DATABASE_PASSWORD} - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT:6379} - jpa: - show-sql: false - -skillhub: - storage: - type: s3 - endpoint: ${S3_ENDPOINT} - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - bucket: ${S3_BUCKET:skillhub} - region: ${S3_REGION:us-east-1} -``` +## 4 开发环境 -### 本地开发启动流程 +开发入口保持不变: ```bash -# 一键启动依赖 + 后端 + 前端 make dev-all ``` -启动后可直接访问: +行为: -- Web UI: `http://localhost:3000` -- Backend API: `http://localhost:8080` +- `docker-compose.yml` 启动 PostgreSQL、Redis、MinIO +- `server` 在宿主机通过 Maven Wrapper 启动 +- `web` 在宿主机通过 Vite 启动 -停止: +常用命令: ```bash +make dev +make dev-all +make dev-down make dev-all-down +make dev-all-reset ``` -如需分步启动: - -```bash -make dev # 仅依赖服务 -make dev-server # 仅后端 -make dev-web # 仅前端 -``` +## 5 单机交付环境 -### Makefile 命令 +### 5.1 启动 ```bash -make dev # 仅启动本地依赖服务 -make dev-all # 一键启动本地依赖 + 后端 + 前端 -make dev-down # 停止本地依赖服务 -make dev-all-down # 停止本地依赖 + 后端 + 前端 -make build # 构建后端 -make generate-api # 生成 OpenAPI 类型 +cp .env.release.example .env.release +make validate-release-config +docker compose --env-file .env.release -f compose.release.yml up -d ``` -## 4 配置管理 +默认访问地址: -- 敏感配置:K8s Secret(数据库/Redis/S3 凭证、OAuth2 Client ID/Secret) -- 非敏感配置:K8s ConfigMap(文件大小限制、Session TTL 等) +- Web UI: `SKILLHUB_PUBLIC_BASE_URL` +- Backend API: `http://localhost:8080` -## 5 可观测性 +### 5.2 关键文件 + +- `compose.release.yml` + - 使用发布镜像,不在用户机器上执行本地构建 + - 负责拉起 PostgreSQL、Redis、server、web + - PostgreSQL、Redis 默认只绑定到 `127.0.0.1` + - Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像 +- `.env.release.example` + - 运行时变量模板 + - 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数 +- `scripts/validate-release-config.sh` + - 在启动前校验 `.env.release` + - 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值 + +### 5.3 镜像标签约定 + +- `edge` + - `main` 分支最新构建 + - 用于内部持续验证 +- `vX.Y.Z` + - 对应 Git tag + - 用于稳定版本交付 +- `latest` + - 仅在语义化版本 tag 发布时更新 + +推荐: + +- 团队内部试用:`SKILLHUB_VERSION=edge` +- 对外演示或文档引用:固定为某个 `vX.Y.Z` + +## 6 GitHub Actions 发布流程 + +发布工作流文件:`.github/workflows/publish-images.yml` + +触发条件: + +- push 到 `main` +- push 语义化版本 tag,例如 `v1.2.0` +- 手动 `workflow_dispatch` + +流程: + +1. 检出代码 +2. 登录 GHCR +3. 分别构建 `server/Dockerfile` 与 `web/Dockerfile` +4. 推送镜像: + - `ghcr.io/iflytek/skillhub-server` + - `ghcr.io/iflytek/skillhub-web` +5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签 +6. 同时发布 `linux/amd64` 与 `linux/arm64` manifest,避免 Apple Silicon / ARM 主机依赖模拟层 + +## 7 配置管理 + +前端运行时配置通过 `web/runtime-config.js.template` 注入。与认证兼容层相关的新变量如下: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` + - 是否在前端打开账号密码兼容接入层 + - 默认应为 `false` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` + - 前端调用 `/api/v1/auth/direct/login` 时使用的 provider,例如 `private-sso` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` + - 是否在前端打开企业 SSO 被动会话兼容入口 + - 默认应为 `false` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` + - 前端调用 `/api/v1/auth/session/bootstrap` 时使用的 provider,例如 `private-sso` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + - 是否在登录页加载后自动尝试一次 bootstrap + - 建议私有版初期保持 `false` + +注意: + +- 前端密码兼容层打开之前,后端仍必须同步打开 `skillhub.auth.direct.enabled=true` +- 前端开关打开之前,后端仍必须同步打开 `skillhub.auth.session-bootstrap.enabled=true` +- 前后端任一侧未开启,都不会破坏原有登录方式;只会使该兼容入口不可用或不显示 + +开发环境: + +- 本地命令与 `docker-compose.yml` +- 非敏感默认值可直接落库或写入本地配置 + +单机交付环境: + +- 使用 `.env.release` 管理 Compose 变量 +- 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io` +- 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release` +- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入 +- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入 +- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` + +## 8 裸金属上线清单 + +推荐顺序: + +1. 准备服务器基础环境 + - 安装 Docker Engine 与 Docker Compose Plugin + - 配置公网 HTTPS 入口,确保最终访问域名已经确定 + - 打开 `80` / `443`,避免直接暴露 `5432` / `6379` +2. 填写 `.env.release` + - `SKILLHUB_PUBLIC_BASE_URL` 填最终 HTTPS 域名,且不要带尾部 `/` + - `SKILLHUB_STORAGE_PROVIDER=s3` + - 按云厂商 OSS / S3 兼容参数填写 `SKILLHUB_STORAGE_S3_*` + - 设置非默认的 `POSTGRES_PASSWORD` + - 如果要启用首登管理员,再额外设置 `BOOTSTRAP_ADMIN_ENABLED=true` 与非默认的 `BOOTSTRAP_ADMIN_PASSWORD` +3. 启动前校验 + - 运行 `make validate-release-config` + - 确认没有 `replace-me`、`change-this-*`、`ChangeMe!2026` 之类的占位值 +4. 首次启动 + - 运行 `docker compose --env-file .env.release -f compose.release.yml up -d` + - 检查 `docker compose --env-file .env.release -f compose.release.yml ps` + - 检查 `curl -i http://127.0.0.1:8080/actuator/health` +5. 首登收尾 + - 仅在启用了 `BOOTSTRAP_ADMIN_ENABLED=true` 时,使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录 + - 立即修改管理员密码 + - 如果后续完全走 OAuth,可将 `BOOTSTRAP_ADMIN_ENABLED=false` + +## 9 可观测性 | 维度 | 方案 | |------|------| -| 日志 | JSON 格式 stdout,包含 traceId/requestId | -| 指标 | Actuator + Micrometer → Prometheus | -| 链路追踪 | 一期 requestId 透传,后续接 Jaeger/Zipkin | -| 告警 | 基于 Prometheus(5xx 率、延迟 P99、Pod 重启) | - -requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志自动携带 → 响应 Header 回传。 - -## 6 构建与发布 - -### CI Pipeline 构建 - -``` -代码提交 → CI Pipeline - ├── server: mvn package → JAR - └── web: pnpm build → dist/ - │ - ▼ - Docker 多阶段构建 - ├── server → eclipse-temurin:21-jre-alpine - └── web → nginx:alpine - │ - ▼ - 推送镜像 → K8s 滚动更新 -``` +| 健康检查 | `web/nginx-health`、`server/actuator/health` | +| 日志 | 容器 stdout / stderr | +| 指标 | Spring Boot Actuator,后续可接 Prometheus | -Makefile 顶层命令:`make dev`, `make dev-all`, `make dev-down`, `make dev-all-down`, `make build`, `make generate-api` +## 10 数据迁移 -## 7 数据库迁移 +Flyway 仍是唯一 schema 变更入口: -Flyway 管理 schema 变更: -- 脚本路径:`server/skillhub-app/src/main/resources/db/migration/` +- 路径:`server/skillhub-app/src/main/resources/db/migration/` - 命名:`V{version}__{description}.sql` -- 多 Pod 安全:Flyway 自带数据库锁 +- 启动策略:应用容器启动时自动执行迁移 diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index 553f2949..f1d5b8c6 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -9,7 +9,7 @@ - 一期同步发布模型,暂不考虑异步发布 - API Token 一期继承用户全部权限(非最小权限),后续版本细化 - CLI 主认证切换为 OAuth Device Flow,Bearer Token 统一用于 CLI API 和兼容层 -- ClawHub CLI 兼容层基地址 `/api/compat/v1`,通过 `/.well-known/clawhub.json` 发现 +- ClawHub CLI 兼容层基地址 `/api/v1`,通过 `/.well-known/clawhub.json` 发现 ## Phase 1:工程骨架 + 认证打通 @@ -49,7 +49,7 @@ - 命名空间 CRUD + 成员管理 - 对象存储集成(LocalFile + S3 双实现) -- 技能发布(上传 → 校验 → 存储 → `PUBLISHED`,一期同步处理) +- 技能发布(上传 → 校验 → 存储 → 审核 / 上线,一期同步处理) - 技能查询(详情、版本、文件)、下载(打包 + 可见性检查,PUBLIC 匿名可下载) - 标签管理、搜索(PostgreSQL Full-Text,匿名搜索限 PUBLIC) - 异步事件基础设施 @@ -64,7 +64,7 @@ ### 验收 -完整发布 → 存储 → 查询 → 下载链路,搜索可用,命名空间隔离生效,匿名用户可浏览/下载公共技能,Phase 2 不经过审核即可完成发布 +完整发布 → 存储 → 审核 → 查询 → 下载链路可用,搜索可用,命名空间隔离生效,匿名用户可浏览/下载公共技能 ## Phase 3:审核流程 + 评分收藏 + CLI API / ClawHub 兼容层 @@ -75,7 +75,7 @@ - 评分 + 收藏 + 计数器(原子更新) - OAuth Device Flow(device code、授权确认、轮询换取 Bearer Token) - CLI API(whoami、publish、resolve、check) -- ClawHub CLI 协议兼容层(`/api/compat/v1` 端点:search、resolve、download、publish、whoami) +- ClawHub CLI 协议兼容层(`/api/v1` 端点:search、resolve、download、publish、whoami) - 兼容层 canonical slug 映射(`--` 双连字符规则) - `/.well-known/clawhub.json` 发现端点 - 协议适配器与兼容性测试(针对 ClawHub CLI 的真实请求/响应样例) @@ -92,7 +92,7 @@ ### 验收 -发布恢复为必须经审核,团队空间自治审核与全局空间平台审核生效,skillhub CLI Device Flow 可用,ClawHub CLI 通过兼容层可完成核心 registry 操作,评分收藏可用 +团队空间自治审核与全局空间平台审核生效,skillhub CLI Device Flow 可用,ClawHub CLI 通过兼容层可完成核心 registry 操作,评分收藏可用 ## Phase 4:运维增强 + 打磨 + 开源就绪 @@ -100,7 +100,7 @@ - 本地认证体系(用户名密码注册/登录 + BCrypt + 密码策略 + 账号锁定) - 多账号合并流程(发起 → 验证 → 确认 → 数据迁移) -- 技能治理(隐藏/恢复 + 版本撤回 YANKED) +- 技能治理(隐藏/恢复 + 已发布版本撤回 YANKED) - 审计日志查询 API(多条件筛选 + 分页) - Prometheus 指标暴露(Actuator + Micrometer 自定义业务指标) - 性能优化(数据库索引 + S3 预签名 URL + 连接池调优) @@ -111,7 +111,7 @@ - 注册页、登录页扩展(用户名密码 + OAuth 双模式) - 密码修改页、账号合并页 - 审计日志查询页 -- 技能隐藏/撤回操作(管理员可见) +- 技能隐藏/恢复/已发布版本撤回操作(管理员可见) - 前端代码分割(TanStack Router lazy routes) - rehype-sanitize XSS 防护 - OpenAPI SDK 工程化:生成文件纳入 CI 校验,避免新增接口回退到手写调用 @@ -125,22 +125,23 @@ ### 验收 -本地认证可用,多账号合并可用,技能隐藏/撤回可用,审计日志可查询,Prometheus 指标可拉取,`docker compose up` 一键启动,K8s 清单可部署,开源基础设施齐全 +本地认证可用,多账号合并可用,技能隐藏/恢复/已发布版本撤回可用,审计日志可查询,Prometheus 指标可拉取,`docker compose up` 一键启动,K8s 清单可部署,开源基础设施齐全 ## Phase 5:治理闭环 + 社交 - 评论功能 -- 举报/标记机制(用户举报 → 管理员处理 → 隐藏/撤回) -- 自动安全预检(`PrePublishValidator` 实现:敏感信息扫描、恶意脚本检测) +- 举报/标记机制(用户举报 → 管理员处理 → 隐藏/已发布版本撤回) +- 自动安全预检(`PrePublishValidator` 从当前 `NoOp` 扩展为真实校验链) - Webhook/事件通知(发布通知、审核结果通知) - 后续 OAuth Provider 扩展(GitLab、Google 等) +- 向量搜索第二阶段增强(当前第一阶段仅做搜索增强,不做推荐) ## 主要风险与应对 | 风险 | 应对 | |------|------| | GitHub OAuth 回调配置复杂 | 本地用 MockAuthFilter 解耦,OAuth 联调可并行 | -| 审核流程需求变更 | skill_version.status 已预留审核状态 | +| 审核流程需求变更 | 生命周期状态机已收敛到 `DRAFT / PENDING_REVIEW / PUBLISHED / REJECTED / YANKED`,读模型通过统一 projection 暴露 | | 搜索效果不佳 | SPI 架构允许随时切换实现 | | 前后端接口频繁变更 | OpenAPI spec 先行,类型自动生成 | | 新增 OAuth Provider | Spring Security OAuth2 原生多 Provider 支持,只需配置 + 属性映射 | diff --git a/docs/11-auth-extensibility-and-private-sso.md b/docs/11-auth-extensibility-and-private-sso.md new file mode 100644 index 00000000..dcf66029 --- /dev/null +++ b/docs/11-auth-extensibility-and-private-sso.md @@ -0,0 +1,148 @@ +# 认证扩展与私有 SSO 兼容设计 + +## 1. 目标 + +在不影响当前开源版 OAuth 和本地账号登录能力的前提下,为未来私有仓库接入企业 SSO 预留稳定扩展点,并把代码差异控制在 provider 实现层和少量配置层。 + +## 2. 已确认约束 + +- 私有 SSO 能提供稳定唯一 UID +- 用户名密码校验与 Cookie 会话校验都会返回同一稳定 UID +- 生产部署预期为 `skill.xxx.com` 与 `sso.xxx.com` +- 私有版可通过后端内部接口/RPC 代调用 SSO 校验用户名密码 +- 首次 SSO 登录自动创建 skillhub 账号 +- 不做账号合并设计,不依赖 email +- 登出联动可保留扩展点,但不是近期目标 + +## 3. 开源版兼容策略 + +### 3.1 不改变现有主链路 + +- 现有 OAuth 登录流程保持不变 +- 现有本地用户名密码登录保持不变 +- 现有 `/api/v1/auth/providers` 协议保持不变 +- 不在开源版中引入私有 SSO 的真实实现 + +### 3.2 新增的公共扩展协议 + +开源版新增显式被动会话引导接口: + +- `POST /api/v1/auth/session/bootstrap` + +请求: + +```json +{ + "provider": "private-sso" +} +``` + +行为约束: + +- 默认关闭,由 `skillhub.auth.session-bootstrap.enabled=false` 控制 +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 外部会话校验失败时返回 `401` +- 成功时建立 skillhub Session,并返回当前用户信息 + +同时新增默认关闭的直连认证兼容接口: + +- `POST /api/v1/auth/direct/login` + +请求: + +```json +{ + "provider": "private-sso", + "username": "alice", + "password": "secret" +} +``` + +行为约束: + +- 默认关闭,由 `skillhub.auth.direct.enabled=false` 控制 +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 成功时建立 skillhub Session,并返回当前用户信息 +- 开源版仍保留原始 `/api/v1/auth/local/login` + +### 3.3 代码级扩展点 + +```java +public interface PassiveSessionAuthenticator { + String providerCode(); + Optional authenticate(HttpServletRequest request); +} +``` + +```java +public interface DirectAuthProvider { + String providerCode(); + PlatformPrincipal authenticate(DirectAuthRequest request); +} +``` + +私有版只需要新增实现,例如: + +- `private-sso-cookie`:读取共享 Cookie 并向 SSO 校验 +- 后续如果需要,也可以补“用户名密码直连认证 provider”扩展点 + +为减少私有 fork 的前端硬编码,扩展 provider 可额外声明展示名称: + +- `DirectAuthProvider.displayName()` 默认回退为 `providerCode()` +- `PassiveSessionAuthenticator.displayName()` 默认回退为 `providerCode()` +- `GET /api/v1/auth/methods` 会返回该展示名称,供登录页直接渲染 + +## 4. 本轮已落地内容 + +- 新增 `PassiveSessionAuthenticator` SPI +- 新增 `DirectAuthProvider` SPI +- 新增统一会话建立服务 `PlatformSessionService` +- 新增 `POST /api/v1/auth/session/bootstrap` 协议 +- 新增 `POST /api/v1/auth/direct/login` 协议 +- 新增 `skillhub.auth.direct.enabled` 开关,默认关闭 +- 新增 `skillhub.auth.session-bootstrap.enabled` 开关,默认关闭 +- 前端新增基于运行时配置的账号密码兼容接入层 +- 前端新增基于运行时配置的被动会话兼容入口 +- 前端新增显式按钮和可选自动尝试逻辑,默认都不启用 +- 增加 controller 集成测试,验证: + - 默认关闭时不会影响现有系统 + - 启用并提供 authenticator 时可以建立 skillhub Session + +统一会话建立约束: + +- 本地登录、OAuth 成功回调、direct auth、session bootstrap、mock 登录旁路都走 `PlatformSessionService` +- 会话写入统一依赖 `HttpSession` 属性:`platformPrincipal` 与 `SPRING_SECURITY_CONTEXT` +- 因此在生产环境启用 Spring Session Redis 时,不需要为不同登录方式分别处理 Session 序列化或存储逻辑 +- 交互式登录默认轮换 session id;OAuth 这类已在 Spring Security 认证链中的流程复用现有 `Authentication` + +前端运行时配置: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + +使用方式: + +1. 若要做密码直连,后端启用 `skillhub.auth.direct.enabled=true` +2. 私有版提供 `DirectAuthProvider` 实现 +3. 前端设置 `SKILLHUB_WEB_AUTH_DIRECT_*` +4. 若要做被动会话,后端启用 `skillhub.auth.session-bootstrap.enabled=true` +5. 私有版提供 `PassiveSessionAuthenticator` 实现 +6. 前端设置 bootstrap provider 和开关 +7. 登录页显示兼容入口,或在配置允许时自动尝试一次 bootstrap + +## 5. 后续建议 + +- 私有版实现 `DirectAuthProvider` 和 / 或 `PassiveSessionAuthenticator` 时,只扩展 provider 层,不复制 session 建立逻辑 +- 私有版优先采用显式 bootstrap,而不是透明全局拦截器自动登录 +- 如后续需要登出联动,只通过 `LogoutPropagationHandler` 扩展,不改动现有主登出链路 + +## 6. 实施手册 + +更详细的私有 SSO 接入步骤、最佳实践、测试矩阵和给后续 coding agent 的执行约束,见: + +- [12-private-sso-integration-playbook.md](/Users/xudongsun/github/skillhub/docs/12-private-sso-integration-playbook.md) diff --git a/docs/12-private-sso-integration-playbook.md b/docs/12-private-sso-integration-playbook.md new file mode 100644 index 00000000..786317fb --- /dev/null +++ b/docs/12-private-sso-integration-playbook.md @@ -0,0 +1,438 @@ +# 私有 SSO 接入兼容层实施手册 + +## 1. 文档目的 + +本文档面向两类读者: + +- 后续在私有仓库中接入企业 SSO 的开发者 +- 需要基于当前开源版兼容层继续开发的 coding agent + +本文档不是认证架构总览,而是实施手册。目标是让后续执行者在不了解全部历史上下文的情况下,也能基于当前成果直接开始接入工作,并且尽量把私有仓库与开源仓库的差异控制在 provider 实现层和少量配置层。 + +相关文档: + +- [03-authentication-design.md](/Users/xudongsun/github/skillhub/docs/03-authentication-design.md) +- [06-api-design.md](/Users/xudongsun/github/skillhub/docs/06-api-design.md) +- [08-frontend-architecture.md](/Users/xudongsun/github/skillhub/docs/08-frontend-architecture.md) +- [11-auth-extensibility-and-private-sso.md](/Users/xudongsun/github/skillhub/docs/11-auth-extensibility-and-private-sso.md) + +## 2. 当前上下文与已确认约束 + +本轮改造的真实目标不是在开源版里实现私有 SSO,而是先把开源版前后端改造成一个稳定的兼容接入层。 + +已经确认的业务前提如下: + +- 私有 SSO 能返回稳定且唯一的 UID +- 用户名密码校验接口与基于 Cookie 的会话校验接口都返回同一个 UID +- SkillHub 私有版与私有 SSO 会部署在统一主域下,例如 `skill.xxx.com` 与 `sso.xxx.com` +- 私有版可以通过内部接口或 RPC 调用 SSO 的用户名密码校验能力 +- 首次 SSO 登录自动创建 SkillHub 账号 +- 不考虑账号合并 +- 不依赖 email 字段 +- 不要求联动登出,但可保留低优先级扩展点 + +这意味着后续私有 SSO 的正确接入方式是: + +- 把 SSO 建模为新的认证来源 `private-sso` +- 用 `providerCode + subject` 表示外部身份,其中 `subject` 就是 SSO UID +- 复用当前平台的统一 Session 建立逻辑,而不是再造一套登录态机制 + +## 3. 当前兼容层已经提供了什么 + +### 3.1 后端扩展点 + +当前开源版已经提供以下后端兼容能力: + +- `DirectAuthProvider` + - 用于“前端收集用户名密码,后端调用外部系统校验”的模式 +- `PassiveSessionAuthenticator` + - 用于“浏览器自动带上 SSO Cookie,后端读取请求并向 SSO 校验”的模式 +- `PlatformSessionService` + - 用于统一建立 SkillHub Web Session +- `LogoutPropagationHandler` + - 用于未来低优先级登出联动 + +关键代码位置: + +- [DirectAuthProvider.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java) +- [PassiveSessionAuthenticator.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java) +- [PlatformSessionService.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java) + +### 3.2 后端公共协议 + +当前开源版已经提供以下兼容协议: + +- `POST /api/v1/auth/direct/login` +- `POST /api/v1/auth/session/bootstrap` +- `GET /api/v1/auth/methods` + +这些协议的设计原则如下: + +- 默认关闭 +- 默认没有私有 SSO 实现 +- 启用后由 provider 扩展驱动 +- 成功后统一建立标准 Spring Security Session +- 不替换现有 `/api/v1/auth/local/login` +- 不替换现有 OAuth 登录 + +### 3.3 前端兼容层 + +当前开源版前端已经支持通过运行时配置开启兼容入口: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + +前端设计原则如下: + +- 默认不启用任何私有登录入口 +- 开启后通过兼容层切换,不破坏现有登录页默认行为 +- 优先走统一目录接口 `/api/v1/auth/methods` +- 被动会话登录优先使用显式 bootstrap,而不是页面加载时偷偷尝试多次 + +## 4. 私有 SSO 的推荐接入方案 + +### 4.1 推荐总策略 + +最佳实践不是只选一种方式,而是同时支持两条链路: + +1. 主路径:`DirectAuthProvider` + - 登录页展示企业 SSO 用户名密码表单 + - 后端通过内部接口或 RPC 调用私有 SSO 校验 + - 校验成功后给用户建立 SkillHub Session + +2. 补充路径:`PassiveSessionAuthenticator` + - 当用户已经在 SSO 系统登录过,并且浏览器会自动带上共享 Cookie 时 + - 登录页允许用户主动点击“从企业 SSO 登录” + - 或在非常谨慎的前提下自动尝试一次 bootstrap + +这样做的理由: + +- 覆盖“尚未登录 SSO”和“已登录 SSO”两种用户状态 +- 不依赖浏览器一定已持有 Cookie +- 不把所有登录成功率押在 Cookie 域、SameSite、过期策略等细节上 +- 不改变开源版原始登录逻辑 + +### 4.2 不推荐的做法 + +以下做法不建议在私有版采用: + +- 在全局 servlet filter 中对所有匿名请求自动尝试 SSO 登录 +- 直接在 controller、filter 或 provider 里手写 `HttpSession` 和 `SecurityContext` 逻辑 +- 把私有 SSO 的 UID 映射成临时整数 ID 再作为用户主标识 +- 按 email 自动合并账号 +- 让前端直接调用私有 SSO 的内部校验接口 +- 为私有版新增一整套与开源版平行的“私有登录 session 机制” + +## 5. 私有版最小差异实施方案 + +### 5.1 后端应新增什么 + +私有仓库建议只新增以下实现类,不改主链路: + +1. 一个 `DirectAuthProvider` 实现 +2. 一个 `PassiveSessionAuthenticator` 实现 +3. 可选的 `LogoutPropagationHandler` 实现 +4. 私有配置属性类或私有配置项 +5. 若 SSO 返回的是外部 UID 而不是现成平台用户,需要补充“根据 SSO UID 查询或创建平台用户”的私有服务 + +建议命名示例: + +- `PrivateSsoDirectAuthProvider` +- `PrivateSsoPassiveSessionAuthenticator` +- `PrivateSsoLogoutPropagationHandler` +- `PrivateSsoProperties` +- `PrivateSsoIdentityService` + +不建议修改这些公共类的职责: + +- `PlatformSessionService` +- `LocalAuthController` +- `AuthController` +- `SecurityConfig` + +### 5.2 后端建议实现步骤 + +#### 步骤 1:定义 provider code + +私有版统一使用稳定 provider code: + +```text +private-sso +``` + +要求: + +- `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值 +- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code +- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射 + +#### 步骤 2:封装 SSO 客户端 + +不要在 provider 实现里直接散落 HTTP 或 RPC 调用。建议先抽一层私有客户端: + +```java +public interface PrivateSsoClient { + PrivateSsoUser verifyPassword(String username, String password); + Optional verifySession(HttpServletRequest request); +} +``` + +其中 `PrivateSsoUser` 至少应包含: + +- `uid` +- `username` +- `displayName` + +最佳实践: + +- 所有超时、重试、日志脱敏、错误码翻译都放在客户端层 +- provider 层只负责把外部结果映射成平台所需的身份对象 +- 禁止记录明文密码 + +#### 步骤 3:实现用户映射服务 + +私有 SSO 不依赖 email,也不做账号合并,因此建议私有版实现一个专用服务: + +```java +public interface PrivateSsoIdentityService { + PlatformPrincipal resolveOrCreate(PrivateSsoUser ssoUser); +} +``` + +推荐逻辑: + +1. 按 `providerCode=private-sso` 和 `subject=ssoUid` 查现有绑定 +2. 若已存在,加载对应平台用户 +3. 若不存在,则自动创建平台用户 +4. 创建新的身份绑定 +5. 返回 `PlatformPrincipal` + +要求: + +- 自动创建出的用户默认应是 `ACTIVE` +- 不要尝试和现有本地账号或 OAuth 账号按 email 合并 + +#### 步骤 4:实现 `DirectAuthProvider` + +伪代码如下: + +```java +@Component +public class PrivateSsoDirectAuthProvider implements DirectAuthProvider { + + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + PrivateSsoUser ssoUser = privateSsoClient.verifyPassword( + request.username(), + request.password() + ); + return privateSsoIdentityService.resolveOrCreate(ssoUser); + } +} +``` + +要求: + +- 只返回认证成功后的 `PlatformPrincipal` +- 不在这里建立 Session +- 不在这里写 `SecurityContext` + +#### 步骤 5:实现 `PassiveSessionAuthenticator` + +伪代码如下: + +```java +@Component +public class PrivateSsoPassiveSessionAuthenticator implements PassiveSessionAuthenticator { + + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(HttpServletRequest request) { + return privateSsoClient.verifySession(request) + .map(privateSsoIdentityService::resolveOrCreate); + } +} +``` + +要求: + +- 只消费当前请求已带上的 Cookie 或其他被动凭证 +- 不主动重定向到 SSO +- 不在这里自行创建 Session + +#### 步骤 6:开启配置 + +私有版部署时启用: + +```yaml +skillhub: + auth: + direct: + enabled: true + session-bootstrap: + enabled: true +``` + +建议: + +- 预发环境先只开 direct auth +- passive bootstrap 在确认 Cookie 域和 SameSite 行为可靠后再开启 + +## 6. 前端最佳实践 + +### 6.1 推荐的登录页策略 + +私有版推荐保留当前开源登录页结构,但增加企业 SSO 入口: + +- 保留 OAuth 按钮 +- 本地账号登录是否保留,由私有版自行决定 +- 增加企业 SSO 用户名密码表单,或将现有密码表单切换到 direct auth 兼容接口 +- 增加“从企业 SSO 登录”按钮,对应 `session/bootstrap` + +推荐优先级: + +1. 首先提供明确可见的企业用户名密码登录 +2. 其次提供“从企业 SSO 登录”按钮 +3. 最后才考虑自动 bootstrap + +### 6.2 自动 bootstrap 的使用建议 + +只有在以下条件同时满足时才建议开启 `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO=true`: + +- 已确认浏览器在 `skill.xxx.com` 下能稳定带上 SSO Cookie +- 失败时 UI 不会卡死或重复重试 +- 页面只会自动尝试一次 +- 前端不会因为自动尝试失败而阻断正常密码登录 + +如果以上条件不满足,建议只显示一个显式按钮,让用户主动触发。 + +### 6.3 前端禁止事项 + +- 不要把密码提交给非 SkillHub 后端地址 +- 不要在浏览器里解析或操作私有 SSO 内部 Cookie 细节 +- 不要把 bootstrap 失败当成页面级致命错误 + +## 7. Spring Session Redis 相关约束 + +当前平台的统一 Web 登录态是 Spring Session。 + +后续私有版继续接入时,必须遵守以下规则: + +- 所有成功登录都必须通过 `PlatformSessionService` +- 所有 Web 会话都通过 `HttpSession` 持久化 +- 不要手动维护第二份“私有 SSO session” +- 不要在 Redis 中自行定义另一套认证缓存结构来替代 Session + +当前统一服务会做的事: + +- 写入 `platformPrincipal` +- 写入 `SPRING_SECURITY_CONTEXT` +- 在交互式登录流程中轮换 session id + +## 8. 安全最佳实践 + +### 8.1 用户名密码直连场景 + +- SkillHub 后端与私有 SSO 之间必须走内网或可信 RPC +- 明文密码只允许存在于浏览器提交和后端调用 SSO 的瞬时链路中 +- 日志、埋点、异常信息中禁止出现密码 +- 对下游 SSO 调用应设置超时和熔断策略 + +### 8.2 Cookie 被动会话场景 + +- 必须先确认 Cookie 域、路径、SameSite、Secure 策略能满足 `skill.xxx.com` 使用 +- bootstrap 接口应保留 CSRF 防护 +- 失败时只返回认证失败,不泄露过多 Cookie 校验细节 +- 除非有明确产品要求,否则不要做无感知的全站自动登录 filter + +### 8.3 身份映射场景 + +- 只信任稳定 UID,不信任显示名作为主身份依据 +- 不按 email 合并 +- 不按 username 合并 + +## 9. 建议测试矩阵 + +### 9.1 后端单元测试 + +- `DirectAuthProvider` 成功认证 +- `DirectAuthProvider` 认证失败 +- `PassiveSessionAuthenticator` 在有效 Cookie 下成功返回主体 +- `PassiveSessionAuthenticator` 在无效 Cookie 下返回空或失败 +- `PrivateSsoIdentityService` 首次登录自动建号 +- `PrivateSsoIdentityService` 再次登录复用已有绑定 + +### 9.2 后端集成测试 + +- `POST /api/v1/auth/direct/login` 在开启配置后能建立 Session +- `POST /api/v1/auth/session/bootstrap` 在开启配置后能建立 Session +- 成功登录后 `/api/v1/auth/me` 返回正确用户 +- direct auth 与现有 `/api/v1/auth/local/login` 不互相影响 +- bootstrap 关闭时仍返回 `403` +- direct auth 关闭时仍返回 `403` + +### 9.3 前端测试 + +- 未开启运行时开关时,登录页与开源版默认行为一致 +- 开启 direct auth 后,密码表单请求走 `/api/v1/auth/direct/login` +- 开启 bootstrap 按钮后,点击能触发 bootstrap 请求 +- 自动 bootstrap 失败后,用户仍可正常使用其它登录入口 + +### 9.4 手工验收 + +- 已登录 SSO 的浏览器中,bootstrap 能成功建立 SkillHub 登录态 +- 未登录 SSO 的浏览器中,bootstrap 失败但不影响密码登录 +- direct auth 登录成功后,刷新页面仍保持登录态 +- 多 Pod 环境下,借助 Spring Session Redis,切换实例后 session 仍有效 + +## 10. 推荐开发顺序 + +如果后续在私有仓库中真正开始接入,建议按下面顺序推进: + +1. 实现 `PrivateSsoClient` +2. 实现 `PrivateSsoIdentityService` +3. 实现 `PrivateSsoDirectAuthProvider` +4. 先启用 `skillhub.auth.direct.enabled=true` +5. 前端接通 direct auth 入口并完成测试 +6. 再实现 `PrivateSsoPassiveSessionAuthenticator` +7. 确认 Cookie 作用域和浏览器行为 +8. 启用 `session-bootstrap` +9. 视需要决定是否开启自动 bootstrap + +## 11. 给 coding agent 的执行指令 + +如果后续由 AI 继续在私有仓库上完成接入,建议严格遵守以下执行规则: + +- 先读 [11-auth-extensibility-and-private-sso.md](/Users/xudongsun/github/skillhub/docs/11-auth-extensibility-and-private-sso.md) 和本文档 +- 不要重构现有公共认证主链路,除非发现明确 bug +- 私有 SSO 的具体实现优先写成 provider、authenticator、client、identity service +- 不要复制 `PlatformSessionService` 逻辑 +- 不要在多个 controller 或 filter 中重复写 Session 建立代码 +- 任何新增前端行为都必须保证运行时配置关闭时完全不影响开源版 +- 所有新增协议和运行时配置必须同步更新文档 +- 每完成一个阶段都跑后端测试;涉及前端改动时再补跑 `pnpm typecheck` 和 `pnpm build` + +## 12. 完成定义 + +当私有版 SSO 接入完成时,应满足以下标准: + +- 开源版默认登录方式仍然不变 +- 私有版只通过扩展点接入,没有复制一套独立登录架构 +- direct auth 可用 +- session bootstrap 可用 +- 首次 SSO 登录自动建号 +- 统一使用 Spring Session Redis 承载 Web 登录态 +- `/api/v1/auth/me`、RBAC、现有业务接口对登录来源无感知 +- 文档、配置、测试都完整 diff --git a/docs/13-parallel-workflow.md b/docs/13-parallel-workflow.md new file mode 100644 index 00000000..4653f272 --- /dev/null +++ b/docs/13-parallel-workflow.md @@ -0,0 +1,173 @@ +# Claude + Codex Parallel Workflow + +This document defines the recommended way to run Claude and Codex in parallel on SkillHub without letting them overwrite each other. + +Legacy aliases `agent-worktrees` and `agent-sync` still work as compatibility shims, but the canonical command set is `parallel-init`, `parallel-sync`, `parallel-up`, and `parallel-down`. + +## Goals + +- Keep Claude and Codex isolated while they write code. +- Reserve a single browser test environment at `http://localhost:3000`. +- Make integration predictable before opening a pull request. + +## Core Rule + +Do not let two agents write to the same checkout at the same time. + +Instead, use three sibling git worktrees per task: + +- `...-claude-`: Claude writes code here +- `...-codex-`: Codex writes code here +- `...-integration-`: merged result, browser verification, final regression + +Only the integration worktree should run `make dev-all`. That keeps `http://localhost:3000` and `http://localhost:8080` as a single source of truth for the merged feature. + +All worktrees share the same local Docker dependency project name, so `make dev` and `make dev-all` reuse one set of Postgres, Redis, and MinIO containers across checkouts. + +## One-Time Setup Per Task + +From the main repository: + +```bash +make parallel-init TASK=legal-pages +``` + +This creates: + +- `../skillhub-claude-legal-pages` +- `../skillhub-codex-legal-pages` +- `../skillhub-integration-legal-pages` + +And matching local branches: + +- `agent/claude/legal-pages` +- `agent/codex/legal-pages` +- `agent/integration/legal-pages` + +You can override the base branch or destination root: + +```bash +make parallel-init TASK=legal-pages PARALLEL_BASE_REF=origin/main PARALLEL_WORKTREE_ROOT=/Users/wowo/workspace +``` + +If you are iterating on this workflow itself before it lands on `origin/main`, create the worktrees from your current branch instead: + +```bash +make parallel-init TASK=legal-pages PARALLEL_BASE_REF=HEAD +``` + +## Recommended Responsibility Split + +Keep the split coarse and explicit before either agent starts: + +- Claude: requirements, review, copy, UI structure, edge cases, regression review +- Codex: implementation, wiring, scripts, tests, refactors, fixes + +Avoid assigning the same file to both agents. If both agents must touch one file, switch to sequential editing for that file. + +## Daily Loop + +### 1. Parallel implementation + +Run each agent only in its assigned worktree: + +- Claude works in `../skillhub-claude-` +- Codex works in `../skillhub-codex-` + +Each agent should commit its own work before integration. + +### 2. Merge into integration + +From the integration worktree: + +```bash +cd ../skillhub-integration-legal-pages +make parallel-up +``` + +This does both routine steps in one command: + +- merges the default source branches into the current integration branch +- starts the integration worktree with `make dev-all` + +The default source branches are: + +- `agent/claude/legal-pages` +- `agent/codex/legal-pages` + +If you need manual control, you can still split the flow: + +```bash +cd ../skillhub-integration-legal-pages +make parallel-sync +make dev-all +``` + +If needed, you can override the source list on either command: + +```bash +make parallel-sync SOURCES="agent/claude/legal-pages agent/codex/legal-pages" +make parallel-up SOURCES="agent/claude/legal-pages agent/codex/legal-pages" +``` + +If a merge conflict happens, resolve it in the integration worktree. Do not resolve it inside the Claude or Codex worktree unless you intentionally want to rewrite that branch. + +### 3. Browser verification + +Run the local stack only in the integration worktree: + +```bash +cd ../skillhub-integration-legal-pages +make parallel-up +``` + +Then verify the merged result in the browser: + +- Web UI: `http://localhost:3000` +- Backend: `http://localhost:8080` + +When you are done: + +```bash +make parallel-down +``` + +## Validation Checklist + +Before opening a PR from the integration branch: + +1. Run the smallest relevant local verification first, for example `pnpm --dir web typecheck` or backend tests. +2. From the integration worktree, run `make parallel-up`. +3. Verify the final merged behavior in `http://localhost:3000`. +4. Run `make staging` if the change needs Docker-path or smoke-test confidence. + +## Recovery Rules + +- If one agent’s branch goes in the wrong direction, reset or discard that branch only in its own worktree. +- If integration becomes messy, recreate only the integration worktree branch from the original base and merge again. +- If ports are already in use, make sure no Claude or Codex worktree is running its own local stack. + +## PR Strategy + +There are two safe choices: + +- Open the PR from the integration branch after merged verification passes. +- Or cherry-pick the validated commits onto a clean feature branch and open the PR from there. + +For this repository, the simplest path is usually: + +1. Claude branch commits +2. Codex branch commits +3. In the integration worktree, run `make parallel-up` +4. Verify on `localhost:3000` +5. Open the PR from integration + +## Commands Summary + +```bash +make parallel-init TASK=legal-pages +cd ../skillhub-integration-legal-pages +make parallel-up +open http://localhost:3000 +make parallel-down +``` diff --git a/docs/14-skill-lifecycle.md b/docs/14-skill-lifecycle.md new file mode 100644 index 00000000..197f5558 --- /dev/null +++ b/docs/14-skill-lifecycle.md @@ -0,0 +1,155 @@ +# Skill Lifecycle + +Date: 2026-03-18 +Status: current code-aligned reference + +本文件是 skill 生命周期的单一规范入口。结论以当前代码实现为准,并已经同步到领域模型、业务流程、API、前端、搜索和兼容层文档。 + +## 1. 设计原则 + +- skill 生命周期不再被建模为一个混杂状态机,而是拆分为容器状态、版本状态、审核工作流状态和可见性覆盖层 +- 前端不再从 `status + hidden + latestVersionStatus + viewingVersionStatus` 拼装状态,而统一消费后端 lifecycle projection +- destructive action 和 reversible action 必须分离;`withdraw-review` 只表示撤回提审,不表示删除版本 +- 对外仍可保留 `latest` 协议词汇,但内部语义必须严格等价于 latest published + +## 2. 状态模型 + +### 2.1 Skill 容器状态 + +- `ACTIVE` +- `ARCHIVED` + +说明: + +- `hidden` 是独立治理覆盖层,不属于 `Skill.status` +- `SkillStatus.HIDDEN` 不再视为有效生命周期语义 + +### 2.2 SkillVersion 版本状态 + +- `DRAFT` +- `PENDING_REVIEW` +- `PUBLISHED` +- `REJECTED` +- `YANKED` + +状态含义: + +- `DRAFT`:可再次提交审核或删除的非公开版本 +- `PENDING_REVIEW`:冻结待审版本 +- `PUBLISHED`:当前可分发版本 +- `REJECTED`:审核拒绝后保留的版本 +- `YANKED`:曾发布、现已撤回分发的版本 + +### 2.3 ReviewTask 审核工作流状态 + +- `PENDING` +- `APPROVED` +- `REJECTED` + +`ReviewTask` 仅表达审核流程,不再被前端当作展示态来源。 + +## 3. 核心语义 + +### 3.1 Latest + +- `Skill.latestVersionId` 的唯一语义是 latest published pointer +- 它只能指向 `PUBLISHED` 版本 +- 若 skill 没有任何已发布版本,则允许为 `null` +- `latest` 系统保留标签自动跟随该指针 + +### 3.2 Lifecycle Projection + +详情页、我的技能、我的收藏、搜索等读模型统一基于以下 projection: + +- `headlineVersion`:当前页面主展示版本 +- `publishedVersion`:最新已发布版本 +- `ownerPreviewVersion`:owner / namespace 管理者可见的待审核预览版本 +- `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE` + +约束: + +- 公开浏览、安装、下载、搜索只认 `publishedVersion` +- owner 详情页只有在不存在 `publishedVersion` 时,才允许 `headlineVersion = ownerPreviewVersion` +- promotion、compat latest、默认下载等公开分发行为都只能绑定 `publishedVersion` + +## 4. 代码实际链路 + +### 4.1 首次上传 + +- 普通用户上传后直接创建 `PENDING_REVIEW` 版本 +- 同时创建 `PENDING` review task +- 不会创建初始 `DRAFT` +- 不会更新 `latestVersionId` + +### 4.2 审核通过 + +- `PENDING_REVIEW -> PUBLISHED` +- review task 标记为 `APPROVED` +- `Skill.latestVersionId` 指向该版本 +- skill 展示元数据从发布版本刷新 + +### 4.3 审核拒绝 + +- `PENDING_REVIEW -> REJECTED` +- review task 标记为 `REJECTED` +- 版本保留,可后续删除 + +### 4.4 撤回审核 + +- `withdraw-review` 的统一语义是 `PENDING_REVIEW -> DRAFT` +- 同时删除关联的 `PENDING review_task` +- 该操作是可逆、非破坏性的 +- 当前代码只允许提交人本人撤回 + +### 4.5 重传新版本 + +- 若发现旧的 `PENDING_REVIEW` 版本,会先把旧版本自动降回 `DRAFT` +- 然后创建新的待审版本 +- 自动撤回与手动撤回必须保持同一语义 + +### 4.6 已发布版本重发 + +- rerelease 当前本质上是从已发布版本复制并重新走发布流程 +- 当前实现允许特权路径直接产出新 `PUBLISHED` 版本 +- 该能力应被理解为发布路径特例,不是生命周期展示态 + +### 4.7 隐藏 / 恢复 / 归档 / 撤回已发布版本 + +- 隐藏:只改 `hidden=true` +- 恢复:只改 `hidden=false` +- 归档:`Skill.status = ARCHIVED` +- 取消归档:`Skill.status = ACTIVE` +- yank:`PUBLISHED -> YANKED` + +### 4.8 Yank 后指针修正 + +- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer +- 若仍有其他 `PUBLISHED` 版本,则指向最新一个 +- 若已无任何 `PUBLISHED` 版本,则 `latestVersionId = null` + +## 5. 对外协议约束 + +### 5.1 Public / Search / Compat + +- 对外协议可以继续暴露 `latestVersion`、`latest`、默认下载等概念 +- 但它们都必须严格表示“最新已发布版本” +- compat 层内部实现必须从统一 lifecycle projection 的 `publishedVersion` 映射,不允许自行推导“当前版本” + +### 5.2 Frontend + +- 页面状态展示统一消费 projection +- 不再新增旧兼容字段依赖 +- `hidden` 仅作为治理标记展示,不参与版本状态拼装 + +## 6. 权限边界 + +- `withdraw-review`:仅提交人本人 +- 删除版本:owner 或 namespace 管理者,且仅限 `DRAFT` / `REJECTED` +- 归档 / 取消归档:owner 或 namespace 管理者 +- 隐藏 / 恢复技能、撤回已发布版本:平台技能治理权限 + +## 7. 当前最终约束 + +- 一个 skill 生命周期的唯一规范入口就是本文件 +- 其它文档如 `02-domain-model`、`05-business-flows`、`06-api-design`、`08-frontend-architecture` 必须与本文件保持一致 +- 若后续代码再次改变生命周期语义,应先修改代码,再同步更新本文件和相关子文档 diff --git a/docs/15-backend-time-governance-plan.md b/docs/15-backend-time-governance-plan.md new file mode 100644 index 00000000..17952e50 --- /dev/null +++ b/docs/15-backend-time-governance-plan.md @@ -0,0 +1,241 @@ +# skillhub 后端日期时间治理计划 + +## 1. 当前结论 + +当前主系统已经基本完成 UTC 语义收口: + +- 核心业务时间字段大多已迁到 `Instant` +- 核心事件时间列大多已迁到 `TIMESTAMPTZ` +- 服务层“当前时间”大多已统一走注入 `Clock` +- 普通 API 和后台 DTO 的绝对时间已基本统一输出 UTC ISO-8601 + +系统当前保留的已经不是大范围混用,而是少量兼容尾项。剩余风险主要集中在: + +- 个别旧接口仍允许无时区字符串输入 +- 新增代码如果重新引入 `LocalDateTime.now()`,可能把系统带回默认时区依赖 +- 缺少跨时区自动化回归时,仍可能遗漏边界问题 + +## 2. 目标 + +治理目标不是“所有地方都只用一种类型”,而是统一时间语义: + +- 绝对时间点:统一使用 UTC 语义,Java 使用 `Instant` +- 面向业务输入的本地时间:只有在需求明确要求“本地日历时间”时才允许保留 `LocalDateTime` +- 数据库存储绝对时间点时,统一使用 `TIMESTAMPTZ` +- 对外 API 返回绝对时间点时,统一输出 ISO-8601 UTC 字符串,例如 `2026-03-18T06:30:00Z` +- 不再把没有时区语义的 `LocalDateTime` 继续向领域层传播 + +这里要明确区分: + +- i18n 解决的是语言、文案、本地化展示 +- 时间统一到 UTC 解决的是跨时区一致性 + +## 3. 目标模型 + +建议把后端时间字段分成三类管理: + +### 3.1 系统事件时间 + +适用字段: + +- `createdAt` +- `updatedAt` +- `publishedAt` +- `submittedAt` +- `reviewedAt` +- `hiddenAt` +- `yankedAt` +- `lastUsedAt` +- `revokedAt` +- `readAt` +- `handledAt` +- `tokenExpiresAt` + +约束: + +- Java 类型统一为 `Instant` +- 数据库列统一为 `TIMESTAMPTZ` +- 读写都按 UTC 绝对时间处理 + +### 3.2 业务输入时间 + +适用场景: + +- 用户手工输入一个“到某天某时截止”的字段 +- 规则明确绑定某个业务时区,而不是系统时区 + +约束: + +- 如果该时间代表真实绝对时刻,入口就应要求带时区或明确时区来源,然后在服务层立刻转换为 `Instant` +- 不允许把用户输入的裸 `yyyy-MM-ddTHH:mm:ss` 长期保存在核心领域模型中 + +### 3.3 纯日期字段 + +适用场景: + +- 生日 +- 账期 +- 结算日 +- 自然日统计 + +约束: + +- 使用 `LocalDate` +- 不参与 UTC/时区转换 + +## 4. 现状问题 + +### 4.1 历史问题已基本清理 + +此前系统的主要问题包括: + +- 领域层大量使用 `LocalDateTime` +- 服务层散落 `LocalDateTime.now()` +- 数据库 DDL 大量使用 `TIMESTAMP` +- 兼容层存在隐式 UTC 假设和冲突解释 + +当前这些问题在主链代码中已基本完成治理,保留它们主要是为了说明为什么迁移顺序必须先做基础设施,再做模型与数据库。 + +### 4.2 当前仍存在的实际问题 + +- `ApiTokenService` 仍兼容裸时间字符串输入 +- 尚未建立静态约束来阻止未来重新引入 `LocalDateTime.now()` +- 尚未形成系统性的跨时区回归基线 + +## 5. 治理原则 + +- 先统一新增代码,再迁移存量代码 +- 先统一领域模型,再迁移数据库,再收口 API +- 所有“当前时间”获取统一从 `Clock` 注入,禁止继续散落 `now()` +- 迁移期间优先保证 API 兼容,避免前端和 CLI 同时破坏 +- 对外只暴露明确语义的时间格式,不暴露“无时区但又默认是 UTC”的灰色状态 + +## 6. 分阶段计划 + +### Phase 0:基线审计 + +产出: + +- 全量时间字段清单 +- `LocalDateTime` / `Instant` / `LocalDate` 使用清单 +- `TIMESTAMP` / `TIMESTAMPTZ` 列清单 +- API 请求与响应中的时间字段清单 +- 兼容层中所有 epoch 转换点清单 + +当前状态: + +- 已完成初版盘点 +- 已同步到当前代码真实进展 + +### Phase 1:统一规范与基础设施 + +执行内容: + +- 新增全局 UTC `Clock` +- 配置 Hibernate JDBC 时区为 UTC +- 配置 Jackson UTC 输出 +- 建立“绝对时间用 `Instant`”规范 + +当前状态: + +- 已完成 + +### Phase 2:代码层迁移到 `Instant` + +执行内容: + +- 实体字段改为 `Instant` +- `LocalDateTime.now()` 改为 `Instant.now(clock)` +- 比较逻辑统一为 `Instant` +- DTO 与服务同步迁移 + +当前状态: + +- 主链已基本完成 +- 生产代码中仅剩极少数兼容解析代码保留 `LocalDateTime` + +### Phase 3:数据库迁移到 `TIMESTAMPTZ` + +执行内容: + +- 为核心表新增 Flyway migration +- 明确历史 `TIMESTAMP` 数据按 UTC 解释 + +当前状态: + +- 主链核心事件时间列已基本完成 +- 已落地 migration `V13` 到 `V23` + +### Phase 4:API 契约收口 + +执行内容: + +- 普通 JSON API 中所有绝对时间字段统一输出 UTC 字符串 +- 禁止接口返回裸 `LocalDateTime.toString()` +- 逐步淘汰无时区输入 + +当前状态: + +- 普通 API 与后台 DTO 已基本完成 UTC 输出收口 +- 剩余兼容重点是旧接口对裸时间字符串输入的处理策略 + +### Phase 5:清理与强约束 + +执行内容: + +- 清理遗留兼容时区假设 +- 增加 ArchUnit 或静态扫描规则 +- 增加跨时区测试,例如 `UTC` 与 `Asia/Shanghai` + +当前状态: + +- 尚未完成 +- 这是下一阶段最有价值的工作 + +## 7. 重点技术决策 + +### 7.1 为什么用 `Clock` 而不是只用 `Instant.now()` + +- `Instant` 解决“时间如何表达” +- `Clock` 解决“当前时间从哪里来” +- 推荐组合是 `Instant.now(clock)` + +这使服务层可测试、可固定时间、可避免机器本地时区干扰。 + +### 7.2 是否统一引入 `OffsetDateTime` + +本项目更适合以 `Instant` 作为核心绝对时间类型,原因是: + +- 多数字段表达的是事件发生时刻 +- 业务侧通常不需要保留原始 offset +- `Instant` 更能防止“看起来像本地时间”的误解 + +只有在必须保留调用方原始 offset 的场景下,才考虑 `OffsetDateTime`。 + +### 7.3 `expiresAt` 这类用户输入字段怎么处理 + +长期目标: + +- API 约定输入为 RFC 3339 / ISO-8601 带时区时间 +- 服务层解析后立即转换为 `Instant` + +短期兼容: + +- 旧接口若仍接受裸字符串,应在 controller 或 service 边界集中兜底 +- 必须明确记录这是兼容逻辑,而不是长期契约 + +## 8. 风险与应对 + +| 风险 | 应对 | +|------|------| +| 历史 `TIMESTAMP` 数据真实语义不一致 | 先做抽样和数据画像,必要时分批迁移 | +| 前端或 CLI 已依赖不带时区的旧格式 | 保留短期兼容解析,同时明确废弃计划 | +| 新代码继续引入 `LocalDateTime.now()` | 加静态扫描和 review 规则阻断 | +| 缺少跨时区回归导致边界问题漏检 | 增加 `UTC` / `Asia/Shanghai` 双时区测试矩阵 | + +## 9. 推荐后续顺序 + +1. 为 `LocalDateTime.now()` 和实体层 `LocalDateTime` 增加静态约束 +2. 增加跨时区回归测试 +3. 梳理并逐步淘汰裸时间字符串输入兼容 +4. 对生产历史数据做一次抽样校验,确认所有 `TIMESTAMPTZ` 迁移都符合 UTC 解释假设 diff --git a/docs/16-backend-time-inventory.md b/docs/16-backend-time-inventory.md new file mode 100644 index 00000000..0f46e93b --- /dev/null +++ b/docs/16-backend-time-inventory.md @@ -0,0 +1,238 @@ +# skillhub 后端时间字段台账 + +## 1. 扫描范围 + +本台账基于 `server/skillhub-app`、`server/skillhub-auth`、`server/skillhub-domain`、`server/skillhub-infra`、`server/skillhub-storage` 的当前生产代码与 Flyway migration。 + +目标已经从“摸底问题分布”转为“记录当前真实进展与剩余尾项”。 + +## 2. 当前代码分布 + +### 2.1 生产代码中的 `LocalDateTime` 已基本清空 + +当前生产代码里只剩 1 处兼容解析保留 `LocalDateTime`: + +- `ApiTokenService` + - 用于兼容旧接口传入的裸时间字符串 + - 当前明确按 UTC 解释后转成 `Instant` + +此前集中使用 `LocalDateTime` 的主链区域已完成迁移或收口: + +- 认证与账号: + - `api_token` + - `account_merge_request` + - `user_account` + - `identity_binding` + - `role` + - `user_role_binding` + - `local_credential` +- 核心领域: + - `namespace` + - `namespace_member` + - `skill` + - `skill_version` + - `skill_file` + - `skill_tag` + - `skill_version_stats` + - `skill_report` + - `skill_star` + - `skill_rating` +- 服务层: + - `AccountMergeService` + - `LocalAuthService` + - `SkillPublishService` + - `SkillGovernanceService` + - `ReviewService` + - `PromotionService` + - `SkillReportService` +- DTO 与接口输出: + - `NamespaceResponse` + - `MemberResponse` + - `SkillSummaryResponse` + - `SkillVersionResponse` + - `SkillVersionDetailResponse` + - `TagResponse` + - `AdminUserSummaryResponse` + - `AdminSkillReportSummaryResponse` + +结论: + +- 主系统核心“事件发生时间”已经基本收口成 UTC 绝对时间 +- 当前剩余工作主要是兼容策略、数据库尾项复核和防回归约束 + +### 2.2 `Instant` 已成为主流绝对时间类型 + +当前已稳定使用 `Instant` 的代表区域: + +- 审计: + - `AuditLog` + - `AuditLogItemResponse` +- 通知: + - `UserNotification` +- 审核流程: + - `ReviewTask` + - `PromotionRequest` + - `ReviewTaskResponse` + - `PromotionResponseDto` +- 幂等: + - `IdempotencyRecord` + - `IdempotencyInterceptor` + - `IdempotencyCleanupTask` +- 技能主链: + - `Skill` + - `SkillVersion` + - `SkillTag` + - `SkillFile` + - `SkillVersionStats` +- 认证主链: + - `ApiToken` + - `AccountMergeRequest` + - `UserAccount` + - `IdentityBinding` + - `Role` + - `UserRoleBinding` + - `LocalCredential` + +## 3. 数据库层分布 + +### 3.1 已完成的 `TIMESTAMPTZ` 迁移 + +- `V12__governance_notifications.sql` + - `user_notification.created_at / read_at` +- `V13__api_token_timestamptz.sql` + - `api_token.expires_at / last_used_at / revoked_at / created_at` +- `V14__account_merge_request_timestamptz.sql` + - `account_merge_request.token_expires_at / completed_at / created_at` +- `V15__skill_version_timestamptz.sql` + - `skill_version.published_at / created_at / yanked_at` +- `V16__skill_hidden_at_timestamptz.sql` + - `skill.hidden_at` +- `V17__skill_created_updated_timestamptz.sql` + - `skill.created_at / updated_at` +- `V18__namespace_timestamptz.sql` + - `namespace.created_at / updated_at` + - `namespace_member.created_at / updated_at` +- `V19__skill_secondary_timestamptz.sql` + - `skill_tag.created_at / updated_at` + - `skill_file.created_at` + - `skill_version_stats.updated_at` +- `V20__social_and_skill_report_timestamptz.sql` + - `skill_star.created_at` + - `skill_rating.created_at / updated_at` + - `skill_report.created_at / handled_at` +- `V21__user_account_timestamptz.sql` + - `user_account.created_at / updated_at` +- `V22__auth_supporting_tables_timestamptz.sql` + - `identity_binding.created_at / updated_at` + - `role.created_at` + - `user_role_binding.created_at` + - `local_credential.locked_until / created_at / updated_at` +- `V23__review_and_idempotency_timestamptz.sql` + - `review_task.submitted_at / reviewed_at` + - `promotion_request.submitted_at / reviewed_at` + - `idempotency_record.created_at / expires_at` + +### 3.2 当前状态 + +- 主链核心事件时间列已基本完成 `TIMESTAMPTZ` 收口 +- 初始建表 migration 中仍然能看到旧 `TIMESTAMP` 定义,但已由后续 Flyway 升级覆盖 +- 后续重点不是“大批量迁移”,而是查漏补缺和约束新增 + +## 4. 已解决的高风险热点 + +### 4.1 兼容层时区解释冲突 + +此前: + +- `ClawHubCompatController` 按 `ZoneOffset.UTC` 转 epoch +- `ClawHubRegistryFacade` 按系统默认时区解释 + +当前: + +- 已统一按 UTC 解释绝对时间 +- `ClawHubRegistryFacade` 的 `LocalDateTime` epoch 转换重载已移除 + +### 4.2 服务层散落的 `now()` + +此前热点包括: + +- `ApiTokenService` +- `AccountMergeService` +- `LocalAuthService` +- `SkillPublishService` +- `SkillGovernanceService` +- `ReviewService` +- `PromotionService` +- `SkillReportService` +- 多个实体 `@PrePersist` / `@PreUpdate` + +当前: + +- 服务层当前时间已基本统一为注入 `Clock` +- 实体回调已基本统一为显式 UTC + +## 5. 分批迁移进展 + +### Batch 1:基础设施与治理链路 + +已完成: + +- UTC `Clock` Bean +- Hibernate UTC 配置 +- Jackson UTC 配置 +- `ApiResponseFactory` +- `IdempotencyInterceptor` +- `IdempotencyCleanupTask` +- 审计、通知、审核、幂等链路 + +### Batch 2:认证与账号链路 + +已完成: + +- `ApiToken` / `ApiTokenService` +- `AccountMergeRequest` / `AccountMergeService` +- `LocalCredential` +- `UserAccount` +- `IdentityBinding` +- `Role` +- `UserRoleBinding` +- `LocalAuthService` + +### Batch 3:技能核心领域 + +已完成: + +- `Skill` +- `SkillVersion` +- `SkillFile` +- `SkillTag` +- `SkillVersionStats` +- `Namespace` +- `NamespaceMember` +- `SkillPublishService` +- `SkillGovernanceService` +- `ReviewService` +- `PromotionService` +- `SkillReport` +- `SkillStar` +- `SkillRating` + +### Batch 4:DTO 与 API 契约收口 + +已完成: + +- `NamespaceResponse` +- `MemberResponse` +- `SkillSummaryResponse` +- `SkillVersionResponse` +- `SkillVersionDetailResponse` +- `TagResponse` +- `AdminUserSummaryResponse` +- `AdminSkillReportSummaryResponse` +- `TokenController` 的 UTC 输出收口 + +## 6. 当前剩余尾项 + +- `ApiTokenService` 仍保留对裸 `LocalDateTime` 字符串的兼容解析 +- 需要补静态扫描或 ArchUnit 约束,防止新增 `LocalDateTime.now()` +- 需要做一轮跨时区回归,把 `UTC` / `Asia/Shanghai` 纳入关键测试 diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md new file mode 100644 index 00000000..c5f6dd4d --- /dev/null +++ b/docs/dev-workflow.md @@ -0,0 +1,165 @@ +# Development Workflow + +This document describes the recommended workflow for developing SkillHub locally. + +## Prerequisites + +- Docker Desktop (for dependency services and staging) +- Java 21 (for running the backend locally) +- Node.js 22 + pnpm (for running the frontend locally) +- `gh` CLI (for creating pull requests): https://cli.github.com/ + +## Stage 1: Local Development (fast iteration) + +Use this stage for active development — writing code, fixing bugs, iterating quickly. + +### Start the full local stack + +```bash +make dev-all +``` + +This starts: +- Dependency services (Postgres, Redis, MinIO) via Docker +- Backend (Spring Boot) directly on your machine at http://localhost:8080 +- Frontend (Vite) directly on your machine at http://localhost:3000 + +SkillHub now pins a shared Docker Compose project name for local development, so multiple git worktrees can reuse the same dependency containers instead of fighting over `5432`, `6379`, and `9000`. + +### Backend restarts + +**Frontend:** Vite HMR is enabled by default. Save a file and the browser updates instantly. + +**Backend:** the local server now runs from a packaged Spring Boot jar instead of `spring-boot:run`. This avoids mixed classpaths across `skillhub-app`, `skillhub-auth`, `skillhub-domain`, and other sibling modules. + +After editing backend code, restart the backend explicitly: + +```bash +make dev-server-restart +``` + +If you are running the server in a foreground terminal instead of `make dev-all`, stop it and run `make dev-server` again. Expect a full restart in about 5-10 seconds, including rebuilding the backend modules. + +### Mock authentication + +Two mock users are available in local mode (no password needed): + +| User ID | Role | Header | +|---------------|-------------|----------------------------------| +| `local-user` | Regular user | `X-Mock-User-Id: local-user` | +| `local-admin` | Super admin | `X-Mock-User-Id: local-admin` | + +### Useful commands + +| Command | Description | +|----------------------------------|----------------------------------| +| `make dev-all` | Start full local stack | +| `make dev-all-down` | Stop all local services | +| `make dev-status` | Check status of all services | +| `make dev-logs` | Tail backend logs | +| `SERVICE=frontend make dev-logs` | Tail frontend logs | +| `make dev-all-reset` | Full reset (clears data volumes) | +| `make dev-server-restart` | Restart backend after Java changes | +| `make namespace-smoke` | Run namespace workflow smoke test | +| `make db-reset` | Reset database only | + +### Claude + Codex parallel workflow + +When two agents need to work in parallel, do not point both of them at the same checkout. Create isolated task worktrees instead: + +```bash +make parallel-init TASK=legal-pages +``` + +That creates dedicated Claude, Codex, and integration worktrees as sibling directories. Keep `localhost:3000` reserved for the integration worktree only. + +After the one-time setup, switch to the integration worktree for the daily merge + verification loop: + +```bash +cd ../skillhub-integration-legal-pages +make parallel-up +``` + +Then verify the merged result at http://localhost:3000. + +Because all worktrees share the same local dependency project, you only need one set of Postgres, Redis, and MinIO containers for all of them. + +If you need to inspect or resolve merge conflicts before starting the app, you can still split the flow manually: + +```bash +cd ../skillhub-integration-legal-pages +make parallel-sync +make dev-all +``` + +See [13-parallel-workflow.md](./13-parallel-workflow.md) for the full workflow, responsibilities, merge rules, and recovery guidance. + +## Stage 2: Staging Regression (pre-PR validation) + +Use this stage when a feature or bugfix is complete and you want to verify it works correctly in a Docker environment before pushing. + +### What staging does + +`make staging` runs a **hybrid** Docker environment: +- **Backend**: built as a Docker image from your local source +- **Frontend**: built as static files (`pnpm build`) and served by Nginx +- **Dependencies**: same Postgres/Redis/MinIO as local dev + +This is faster than building both images but still validates the containerized backend and the production Nginx serving path. + +### Run staging + +```bash +make staging +``` + +This will: +1. Build the backend Docker image +2. Build the frontend static files +3. Start all services +4. Run smoke tests against the API +5. Print pass/fail summary + +If all tests pass, the environment stays running at: +- Web UI: http://localhost +- Backend API: http://localhost:8080 + +### Stop staging + +```bash +make staging-down +``` + +### View staging logs + +```bash +make staging-logs # backend logs +SERVICE=web make staging-logs # nginx logs +``` + +## Stage 3: Create Pull Request + +After staging passes: + +```bash +make pr +``` + +This will: +1. Check for uncommitted changes (prompts to commit if any) +2. Push your branch to origin +3. Create a pull request using `gh pr create --fill` + +The PR title and body are auto-populated from your commit messages. + +> **Note:** `make pr` requires an interactive terminal. Do not use it in CI. + +## Full workflow summary + +``` +make dev-all # start local dev +# ... write code, test in browser ... +make staging # regression test in Docker +make staging-down # stop staging +make pr # push + create PR +``` diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md new file mode 100644 index 00000000..28c87140 --- /dev/null +++ b/docs/openclaw-integration-en.md @@ -0,0 +1,311 @@ +# OpenClaw Integration Guide + +This document explains how to configure OpenClaw CLI to connect to a SkillHub private registry for publishing, searching, and downloading skills. + +## Overview + +SkillHub provides a ClawHub-compatible API layer, allowing OpenClaw CLI to seamlessly integrate with private registries. With simple configuration, you can: + +- 🔍 Search for private skills within your organization +- 📥 Download and install skill packages +- 📤 Publish new skills to the private registry +- ⭐ Star and rate skills + +## Quick Start + +### 1. Configure Registry URL + +Set the SkillHub registry address in your OpenClaw configuration: + +```bash +# Via environment variable +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +``` + +### 2. Authentication (Optional) + +For **global namespace (@global) PUBLIC skills**, no login is required to download. Authentication is required for: + +- Team namespace skills (regardless of visibility) +- NAMESPACE_ONLY or PRIVATE skills +- Write operations like publishing, starring, etc. + +```bash +# Using API Token +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN +``` + +#### Obtaining an API Token + +1. Log in to SkillHub Web UI +2. Navigate to **Settings → API Tokens** +3. Click **Create New Token** +4. Set token name and permissions +5. Copy the generated token + +### 3. Search Skills + +```bash +# Search for skills +npx clawhub search email + +# View skill details +npx clawhub info my-skill +``` + +### 4. Install Skills + +```bash +# Install latest published version +npx clawhub install my-skill + +# Install specific version +npx clawhub install my-skill@1.2.0 + +# Install skill from team namespace +npx clawhub install my-namespace--my-skill +``` + +### 5. Publish Skills + +```bash +# Publish a skill (requires appropriate permissions) +npx clawhub publish ./my-skill +``` + +## API Endpoints + +SkillHub compatibility layer provides the following endpoints: + +| Endpoint | Method | Description | Auth Required | +|----------|--------|-------------|---------------| +| `/api/v1/whoami` | GET | Get current user info | Yes | +| `/api/v1/search` | GET | Search skills | Optional | +| `/api/v1/resolve` | GET | Resolve skill version | Optional | +| `/api/v1/download/{slug}` | GET | Download skill (redirect) | Optional* | +| `/api/v1/download` | GET | Download skill (query params) | Optional* | +| `/api/v1/skills/{slug}` | GET | Get skill details | Optional | +| `/api/v1/skills/{slug}/star` | POST | Star a skill | Yes | +| `/api/v1/skills/{slug}/unstar` | DELETE | Unstar a skill | Yes | +| `/api/v1/publish` | POST | Publish a skill | Yes | + +Notes: +- The compatibility layer may still expose the term "latest" externally, but it must strictly mean "latest published version" +- Internally, compat responses should map from the unified lifecycle projection's `publishedVersion` rather than inferring an ad hoc "current version" + +\* Download endpoint authentication requirements: +- **Global namespace (@global) PUBLIC skills**: No authentication required +- **All team namespace skills**: Authentication required +- **NAMESPACE_ONLY and PRIVATE skills**: Authentication required + +## Skill Visibility Levels + +SkillHub supports three visibility levels with the following download permission rules: + +### PUBLIC +- ✅ Anyone can search and view +- ✅ **Global namespace (@global)**: No login required to download +- 🔒 **Team namespaces**: Authentication required to download +- 📍 Suitable for organization-wide, publicly shareable skills + +### NAMESPACE_ONLY +- ✅ Namespace members can search and view +- 🔒 Login required and must be namespace member to download +- 📍 Suitable for team-internal skills + +### PRIVATE +- ✅ Only owner can view +- 🔒 Login required and must be owner to download +- 📍 Suitable for skills under personal development + +**Important Notes**: +- Global namespace (`@global`) PUBLIC skills support anonymous downloads for wide distribution within the organization +- All team namespace skills (including PUBLIC) require authentication to ensure team boundary security + +## Canonical Slug Mapping + +SkillHub internally uses `@{namespace}/{skill}` format, but the compatibility layer automatically converts to ClawHub-style canonical slugs: + +| SkillHub Internal | Canonical Slug | Description | +|-------------------|----------------|-------------| +| `@global/my-skill` | `my-skill` | Global namespace skill | +| `@my-team/my-skill` | `my-team--my-skill` | Team namespace skill | + +OpenClaw CLI uses canonical slug format, and SkillHub handles the conversion automatically. + +## Configuration Examples + +### ClawHub CLI Environment Variables + +ClawHub CLI is configured via environment variables: + +```bash +# Registry configuration +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here +``` + +### Environment Variables + +```bash +# Registry configuration +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here + +# Optional: Skip SSL verification (development only) +export CLAWHUB_SKIP_SSL_VERIFY=false +``` + +## FAQ + +### Q: How do I switch back to public ClawHub? + +```bash +# Unset custom registry +unset CLAWHUB_REGISTRY_URL + +# ClawHub CLI will use the default public registry +``` + +### Q: Getting 403 Forbidden when downloading? + +Possible causes: +1. Skill belongs to a team namespace, authentication required +2. Skill is NAMESPACE_ONLY or PRIVATE, authentication required +3. You're not a member of the namespace +4. API Token has expired + +Solution: +```bash +# Set new token +export CLAWHUB_API_TOKEN=YOUR_NEW_TOKEN + +# Test connection +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer $CLAWHUB_API_TOKEN" +``` + +**Tip**: Global namespace (@global) PUBLIC skills can be downloaded anonymously without authentication. + +### Q: How do I see all skills I have access to? + +```bash +# Search all skills (filtered by permissions) +npx clawhub search "" +``` + +### Q: Permission denied when publishing? + +- Publishing to global namespace (`@global`) requires `SUPER_ADMIN` permission +- Publishing to team namespace requires OWNER or ADMIN role in that namespace +- Contact your administrator for appropriate permissions + +### Q: Which OpenClaw versions are supported? + +SkillHub compatibility layer is designed to work with tools using ClawHub CLI. ClawHub CLI is distributed via npm: + +```bash +# Install ClawHub CLI +npm install -g clawhub + +# Or use npx directly +npx clawhub install my-skill +``` + +If you encounter compatibility issues, please file an issue. + +## API Response Formats + +### Search Response Example + +```json +{ + "results": [ + { + "slug": "my-team--email-sender", + "name": "Email Sender", + "description": "Send emails via SMTP", + "author": { + "handle": "user123", + "displayName": "John Doe" + }, + "version": "1.2.0", + "downloadCount": 150, + "starCount": 25, + "createdAt": "2026-01-15T10:00:00Z", + "updatedAt": "2026-03-10T14:30:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### Version Resolution Response Example + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "downloadUrl": "/api/v1/skills/global/my-skill/versions/1.2.0/download" +} +``` + +### Publish Response Example + +```json +{ + "id": "12345", + "version": { + "id": "67890" + } +} +``` + +## Security Recommendations + +1. **Use HTTPS**: Always use HTTPS in production +2. **Token Management**: + - Rotate API tokens regularly + - Never hardcode tokens in code + - Use environment variables or secret management tools +3. **Least Privilege**: Assign minimum required permissions to tokens +4. **Audit Logs**: Regularly review SkillHub audit logs + +## Troubleshooting + +### Enable Debug Logging + +```bash +# View detailed request logs +DEBUG=clawhub:* npx clawhub search my-skill + +# Or use verbose mode +npx clawhub --verbose install my-skill +``` + +### Test Connection + +```bash +# Test registry connection +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Test search +curl "https://skillhub.your-company.com/api/v1/search?q=test" +``` + +## Further Reading + +- [SkillHub API Design](./06-api-design.md) +- [Skill Protocol Specification](./07-skill-protocol.md) +- [Authentication & Authorization](./03-authentication-design.md) +- [Deployment Guide](./09-deployment.md) + +## Support + +For questions or suggestions: +- 📖 Full Documentation: https://zread.ai/iflytek/skillhub +- 💬 GitHub Discussions: https://github.com/iflytek/skillhub/discussions +- 🐛 Submit Issues: https://github.com/iflytek/skillhub/issues diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md new file mode 100644 index 00000000..2c2f7f44 --- /dev/null +++ b/docs/openclaw-integration.md @@ -0,0 +1,311 @@ +# OpenClaw 集成指南 + +本文档说明如何配置 OpenClaw CLI 连接到 SkillHub 私有注册中心,实现技能的发布、搜索和下载。 + +## 概述 + +SkillHub 提供了与 ClawHub 兼容的 API 层,使得 OpenClaw CLI 可以无缝对接私有注册中心。通过简单的配置,您可以: + +- 🔍 搜索组织内的私有技能 +- 📥 下载和安装技能包 +- 📤 发布新技能到私有注册中心 +- ⭐ 收藏和评分技能 + +## 快速开始 + +### 1. 配置 Registry 地址 + +在 OpenClaw 配置文件中设置 SkillHub 注册中心地址: + +```bash +# 通过环境变量配置 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +``` + +### 2. 登录认证(可选) + +对于**全局命名空间(@global)的公开技能(PUBLIC)**,无需登录即可下载。对于以下情况需要认证: + +- 团队命名空间的技能(无论可见性) +- NAMESPACE_ONLY 或 PRIVATE 技能 +- 发布、收藏等写操作 + +```bash +# 使用 API Token +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN +``` + +#### 获取 API Token + +1. 登录 SkillHub Web UI +2. 进入 **个人设置 → API Tokens** +3. 点击 **创建新 Token** +4. 设置 Token 名称和权限范围 +5. 复制生成的 Token + +### 3. 搜索技能 + +```bash +# 搜索技能 +npx clawhub search email + +# 查看技能详情 +npx clawhub info my-skill +``` + +### 4. 安装技能 + +```bash +# 安装最新已发布版本 +npx clawhub install my-skill + +# 安装指定版本 +npx clawhub install my-skill@1.2.0 + +# 安装团队命名空间下的技能 +npx clawhub install my-namespace--my-skill +``` + +### 5. 发布技能 + +```bash +# 发布技能(需要相应权限) +npx clawhub publish ./my-skill +``` + +## API 端点说明 + +SkillHub 兼容层提供以下端点: + +| 端点 | 方法 | 说明 | 认证要求 | +|------|------|------|----------| +| `/api/v1/whoami` | GET | 获取当前用户信息 | 必需 | +| `/api/v1/search` | GET | 搜索技能 | 可选 | +| `/api/v1/resolve` | GET | 解析技能版本 | 可选 | +| `/api/v1/download/{slug}` | GET | 下载技能(重定向) | 可选* | +| `/api/v1/download` | GET | 下载技能(查询参数) | 可选* | +| `/api/v1/skills/{slug}` | GET | 获取技能详情 | 可选 | +| `/api/v1/skills/{slug}/star` | POST | 收藏技能 | 必需 | +| `/api/v1/skills/{slug}/unstar` | DELETE | 取消收藏 | 必需 | +| `/api/v1/publish` | POST | 发布技能 | 必需 | + +说明: +- 兼容层对外继续使用 “latest” 语义,但这里严格指向“最新已发布版本” +- 兼容层内部实现应从统一 lifecycle projection 的 `publishedVersion` 映射,而不是自行推导“当前版本” + +\* 下载端点认证要求: +- **全局命名空间(@global)的 PUBLIC 技能**:无需认证 +- **团队命名空间的所有技能**:需要认证 +- **NAMESPACE_ONLY 和 PRIVATE 技能**:需要认证 + +## 技能可见性说明 + +SkillHub 支持三种技能可见性级别,下载权限规则如下: + +### PUBLIC(公开) +- ✅ 任何人都可以搜索和查看 +- ✅ **全局命名空间(@global)**:无需登录即可下载 +- 🔒 **团队命名空间**:需要登录认证才能下载 +- 📍 适用于组织内通用的、可公开分享的技能 + +### NAMESPACE_ONLY(命名空间内可见) +- ✅ 命名空间成员可以搜索和查看 +- 🔒 需要登录且是命名空间成员才能下载 +- 📍 适用于团队内部技能 + +### PRIVATE(私有) +- ✅ 仅所有者可以查看 +- 🔒 需要登录且是所有者才能下载 +- 📍 适用于个人开发中的技能 + +**重要说明**: +- 全局命名空间(`@global`)的 PUBLIC 技能支持匿名下载,便于组织内广泛分发 +- 团队命名空间的所有技能(包括 PUBLIC)都需要认证,确保团队边界安全 + +## Canonical Slug 映射规则 + +SkillHub 内部使用 `@{namespace}/{skill}` 格式,但兼容层会自动转换为 ClawHub 风格的 canonical slug: + +| SkillHub 内部坐标 | Canonical Slug | 说明 | +|-------------------|----------------|------| +| `@global/my-skill` | `my-skill` | 全局命名空间技能 | +| `@my-team/my-skill` | `my-team--my-skill` | 团队命名空间技能 | + +OpenClaw CLI 使用 canonical slug 格式,SkillHub 会自动处理转换。 + +## 配置示例 + +### ClawHub CLI 环境变量配置 + +ClawHub CLI 通过环境变量配置: + +```bash +# Registry 配置 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here +``` + +### 环境变量配置 + +```bash +# Registry 配置 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here + +# 可选:跳过 SSL 验证(仅用于开发环境) +export CLAWHUB_SKIP_SSL_VERIFY=false +``` + +## 常见问题 + +### Q: 如何切换回公共 ClawHub? + +```bash +# 取消设置自定义 Registry +unset CLAWHUB_REGISTRY_URL + +# ClawHub CLI 将使用默认的公共注册中心 +``` + +### Q: 下载技能时提示 403 Forbidden? + +可能原因: +1. 技能属于团队命名空间,需要登录 +2. 技能是 NAMESPACE_ONLY 或 PRIVATE,需要登录 +3. 您不是该命名空间的成员 +4. API Token 已过期 + +解决方法: +```bash +# 设置新的 Token +export CLAWHUB_API_TOKEN=YOUR_NEW_TOKEN + +# 测试连接 +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer $CLAWHUB_API_TOKEN" +``` + +**提示**:全局命名空间(@global)的 PUBLIC 技能可以匿名下载,无需认证。 + +### Q: 如何查看我有权访问的所有技能? + +```bash +# 搜索所有技能(会根据权限过滤) +npx clawhub search "" +``` + +### Q: 发布技能时提示权限不足? + +- 发布到全局命名空间(`@global`)需要 `SUPER_ADMIN` 权限 +- 发布到团队命名空间需要是该命名空间的 OWNER 或 ADMIN +- 联系管理员分配相应权限 + +### Q: 支持哪些 OpenClaw 版本? + +SkillHub 兼容层设计兼容使用 ClawHub CLI 的工具。ClawHub CLI 通过 npm 分发: + +```bash +# 安装 ClawHub CLI +npm install -g clawhub + +# 或使用 npx 直接运行 +npx clawhub install my-skill +``` + +如遇到兼容性问题,请提交 Issue。 + +## API 响应格式 + +### 搜索响应示例 + +```json +{ + "results": [ + { + "slug": "my-team--email-sender", + "name": "Email Sender", + "description": "Send emails via SMTP", + "author": { + "handle": "user123", + "displayName": "John Doe" + }, + "version": "1.2.0", + "downloadCount": 150, + "starCount": 25, + "createdAt": "2026-01-15T10:00:00Z", + "updatedAt": "2026-03-10T14:30:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### 版本解析响应示例 + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "downloadUrl": "/api/v1/skills/global/my-skill/versions/1.2.0/download" +} +``` + +### 发布响应示例 + +```json +{ + "id": "12345", + "version": { + "id": "67890" + } +} +``` + +## 安全建议 + +1. **使用 HTTPS**:生产环境务必使用 HTTPS 连接 +2. **Token 管理**: + - 定期轮换 API Token + - 不要在代码中硬编码 Token + - 使用环境变量或密钥管理工具 +3. **权限最小化**:为 Token 分配最小必需权限 +4. **审计日志**:定期检查 SkillHub 审计日志 + +## 故障排查 + +### 启用调试日志 + +```bash +# 查看详细请求日志 +DEBUG=clawhub:* npx clawhub search my-skill + +# 或使用 verbose 模式 +npx clawhub --verbose install my-skill +``` + +### 测试连接 + +```bash +# 测试 Registry 连接 +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 测试搜索 +curl "https://skillhub.your-company.com/api/v1/search?q=test" +``` + +## 进一步阅读 + +- [SkillHub API 设计文档](./06-api-design.md) +- [技能协议规范](./07-skill-protocol.md) +- [认证与授权](./03-authentication-design.md) +- [部署指南](./09-deployment.md) + +## 支持 + +如有问题或建议: +- 📖 查看完整文档:https://zread.ai/iflytek/skillhub +- 💬 GitHub Discussions:https://github.com/iflytek/skillhub/discussions +- 🐛 提交 Issue:https://github.com/iflytek/skillhub/issues diff --git "a/docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" "b/docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" deleted file mode 100644 index 0bb93718..00000000 --- "a/docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" +++ /dev/null @@ -1,363 +0,0 @@ -# SkillHub 代码审查报告 - -## 1. 审查结论 - -当前版本不建议直接作为后续多人并行开发与生产化落地的基线。 - -结论等级:`有条件不通过` - -阻塞原因主要集中在以下几类: - -1. 认证与鉴权存在高风险缺陷,部分接口可被越权使用,设备码登录实现还存在可伪造令牌问题。 -2. 发布状态机与设计文档不一致,`latest` 语义、搜索事件、下载链路会被未审核版本污染。 -3. 上传与存储链路缺少路径安全和流式限制,存在路径穿越、内存耗尽、脏对象遗留等风险。 -4. 自动化质量门禁未闭环,`skillhub-app` 集成测试无法启动,前端 lint 也未通过。 - -## 2. 审查范围 - -本次重点审查了以下内容: - -- 设计文档: - - `docs/01-system-architecture.md` - - `docs/03-authentication-design.md` - - `docs/05-business-flows.md` - - `docs/06-api-design.md` - - `docs/07-skill-protocol.md` - - `docs/09-deployment.md` -- 后端核心代码: - - 认证与安全:`skillhub-auth` - - 发布、查询、下载、评审、提升:`skillhub-domain` + `skillhub-app` - - 搜索:`skillhub-search` - - 存储:`skillhub-storage` -- 前端核心代码: - - `web/src/features/skill/markdown-renderer.tsx` - -本次执行的关键校验: - -- `pnpm run typecheck`:通过 -- `pnpm run lint`:失败 -- `pnpm run build`:通过,但主包体积告警 -- `mvn -q -DskipTests compile`:通过 -- `mvn test`:失败,`skillhub-app` 24 个测试里 19 个错误 - -## 3. 关键发现 - -### [P0] 设备码登录当前会返回可预测的伪令牌,并且存在并发重复签发风险 - -问题位置: - -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java:85-111` -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java:64-75` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java:21-29` - -问题说明: - -- `pollToken()` 在设备码授权成功后直接返回 `token_ + deviceCode`,这不是签名令牌,也不是随机不透明令牌,而是由外部输入可推导得到的占位值。 -- `/api/v1/cli/auth/device/**` 被 `permitAll()` 放开,意味着设备流轮询端点是匿名可访问的,这本身没问题,但前提是返回的必须是正式、不可伪造、可审计、可吊销的令牌。 -- `pollToken()` 采用“先读状态,再写 USED”的非原子流程。两个并发轮询请求可以同时读到 `AUTHORIZED`,从而重复获取访问令牌。 - -影响: - -- 这是认证链路上的核心安全缺陷,风险等级为阻塞上线。 -- 设备授权一旦成功,令牌可被预测,且一次授权可能被多次兑换。 - -修改建议: - -1. 设备流成功后必须调用正式的 token 签发服务,返回随机 opaque token 或签名 JWT。 -2. 设备码消费必须改成 Redis Lua / CAS 方式,保证“仅成功兑换一次”。 -3. 为设备码申请、授权、轮询增加限流和审计。 -4. 补充成功兑换、重复兑换、并发轮询、过期轮询的集成测试。 - -### [P1] 评审与提升流程缺失关键权限校验,已形成越权入口 - -问题位置: - -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:56-64` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java:50-68` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java:54-61` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java:47-78` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:102-126` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java:86-105` - -问题说明: - -- `submitReview()` 只根据 `skillVersionId` 和当前用户提交,不校验提交人是否为该 skill 的 owner、namespace 管理员或被允许的发布者。 -- `submitPromotion()` 只校验源版本已发布、目标 namespace 是 GLOBAL,不校验申请人是否有权提升该 skill。 -- `listPendingReviews()`、`getReviewDetail()`、`getPromotionDetail()` 缺少明确授权判断,当前只要已登录即可读取相关数据,存在流程信息泄露。 - -与设计文档冲突: - -- `docs/05-business-flows.md:104-119` 明确要求“owner 或 namespace admin”才能发起提升。 -- `docs/06-api-design.md` 中 review / promotion 相关接口的权限边界也比当前实现更严格。 - -影响: - -- 任意登录用户理论上可以替别人的草稿发起评审。 -- 任意登录用户理论上可以为别人的 skill 发起提升申请。 -- 待审核数据与审批详情对无关用户暴露。 - -修改建议: - -1. 在 `ReviewService.submitReview()` 前补 owner / namespace ADMIN/OWNER 校验。 -2. 在 `PromotionService.submitPromotion()` 前补 source skill 所属 namespace 的提交权限校验。 -3. `listPendingReviews()`、`getReviewDetail()`、`getPromotionDetail()` 必须按 namespace 或平台角色做访问控制,未授权时返回 `403`,不能返回空列表掩盖问题。 - -### [P1] 发布流程在审核前就覆盖 `latestVersionId` 并发出 `SkillPublishedEvent`,会污染下载、解析、搜索和标签语义 - -问题位置: - -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java:143-156` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java:217-225` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java:68-75` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java:106-124` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java:344-351` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java:44-47` -- `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java:26-39` - -问题说明: - -- `publishFromEntries()` 创建的新版本状态是 `PENDING_REVIEW`,但随后立即: - - 更新 `skill.latestVersionId` - - 发出 `SkillPublishedEvent` -- `downloadLatest()`、`resolveLatestVersion()`、`latest` 保留标签、搜索结果里的 latest version 都依赖 `latestVersionId`。 -- 当一个已发布 skill 再上传新版本时,`latestVersionId` 会被指向一个尚未发布的版本,导致“最新版本”不可下载、不可解析或显示错误。 - -与设计文档冲突: - -- `docs/05-business-flows.md:30-45` 的 Phase 2 定义是“发布后直接 `PUBLISHED` 才更新 latest”。 -- `docs/05-business-flows.md:49-55` 的 Phase 3 定义是“`DRAFT -> PENDING_REVIEW -> PUBLISHED`”,审核通过后才进入发布态。 -- 当前代码实际做成了“创建后直接 `PENDING_REVIEW`,但又按已发布版本处理”,状态语义前后矛盾。 - -影响: - -- `latest` 下载链路会在有待审版本时报错。 -- 搜索索引会被过早重建。 -- 详情页、我的技能页、标签查询都会看到未发布版本。 - -修改建议: - -1. 冻结状态机语义: - - 要么 Phase 2:发布即 `PUBLISHED` - - 要么 Phase 3:上传只创建草稿 / 待审,审核通过后才更新 latest -2. `latestVersionId` 必须只表示“最新已发布版本”。 -3. `SkillPublishedEvent` 只能在版本真正进入 `PUBLISHED` 后发出。 -4. 若需要展示“最新草稿”,单独引入 `latestDraftVersionId` 或查询逻辑,不要复用 published 语义字段。 - -### [P1] 上传链路存在路径穿越与压缩包资源耗尽风险 - -问题位置: - -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java:64-83` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java:28-81` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java:166-189` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java:21-31` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java:61` - -问题说明: - -- Controller 侧对 zip entry 使用 `readAllBytes()`,在验证之前就把每个解压后文件整体读入内存。 -- 包校验器没有校验路径规范化,也没有禁止 `../`、绝对路径、反斜杠路径、重复路径。 -- `LocalFileStorageService.resolve()` 直接 `basePath.resolve(key)`,没有 `normalize()` 和 `startsWith(basePath)` 校验。 -- 攻击者可以构造恶意 zip 路径,例如 `../../outside.txt`,通过 `skills/{skillId}/{versionId}/{filePath}` 最终逃逸本地存储根目录。 - -影响: - -- 本地存储模式下存在任意文件写入风险。 -- 恶意压缩包可造成内存压力甚至进程 OOM。 - -修改建议: - -1. 在解压阶段引入流式校验,限制总解压大小、单文件大小、文件数量。 -2. 对 entry path 做统一规范化,只允许: - - 相对路径 - - `/` 分隔 - - 禁止 `..`、禁止绝对路径、禁止空段 -3. `LocalFileStorageService` 必须在 `normalize()` 后校验目标路径仍位于 `basePath` 内。 -4. 对重复路径在校验阶段直接返回 `400`,不能等数据库唯一键报错。 - -### [P1] `GET /api/v1/skills/**` 过度放开,导致匿名 `500` 与私有元数据泄露 - -问题位置: - -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java:75` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java:40-45` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java:37-46` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java:73-94` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java:29-40` - -问题说明: - -- `SecurityConfig` 直接放开了所有 `GET /api/v1/skills/**`。 -- 但其中至少两类 GET 并不适合匿名访问: - - `GET /api/v1/skills/{skillId}/star` - - `GET /api/v1/skills/{skillId}/rating` - 这两个接口直接解引用 `principal.userId()`,匿名访问会触发空指针,落到全局异常后返回 `500`。 -- 另外: - - `listVersions()` 没有 visibility 校验 - - `listTags()` 没有 visibility 校验 - 这会把 `PRIVATE` / `NAMESPACE_ONLY` skill 的版本信息、标签信息暴露给匿名或无关用户。 - -影响: - -- 安全上属于“鉴权边界与路由策略不一致”。 -- 行为上会出现匿名访问 500,破坏 API 契约。 - -修改建议: - -1. 不要用 `GET /api/v1/skills/**` 一刀切放开,改成白名单到具体公共接口。 -2. 对“需要当前用户上下文”的 GET 接口显式要求认证。 -3. `listVersions()`、`listTags()` 必须补充与详情接口一致的 visibility 校验。 - -### [P1] `skillhub-app` 集成测试当前无法启动,测试信号失真 - -问题位置: - -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java:19-25` -- `server/skillhub-app/src/test/resources/application-test.yml:1-33` -- `server/skillhub-app/target/surefire-reports/com.iflytek.skillhub.controller.AuthControllerTest.txt` - -问题说明: - -- `mvn test` 中,`skillhub-storage`、`skillhub-domain`、`skillhub-auth` 的测试通过,但 `skillhub-app` 的 Spring 上下文无法启动。 -- 直接原因是 `DeviceAuthService` 依赖 `RedisTemplate`,测试环境没有对应 bean。 -- 代码里也没有看到 `verificationUri` 的配置注入实现,当前构造器还要求额外的 `String` 参数,后续即使补齐 Redis bean,也大概率还会继续失败。 - -影响: - -- 目前 controller 层测试的 19 个错误都被同一个装配问题掩盖,真实回归无法被发现。 -- CI 无法提供有效的回归保护。 - -修改建议: - -1. 为设备码服务补全正式配置类: - - `RedisTemplate` - - `@ConfigurationProperties` 形式的 `verificationUri` -2. 测试环境提供 stub / mock bean,确保 app context 可启动。 -3. 优先修复后重新运行 controller 集成测试,再评估剩余问题。 - -### [P2] 存储配置键名与部署文档漂移,S3/MinIO 路径当前并不能真正启用 - -问题位置: - -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageProperties.java:7-15` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java:12-14` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java:19-21` -- `server/skillhub-app/src/main/resources/application.yml:55-58` -- `docker-compose.prod.yml:51-59` -- `docs/09-deployment.md` - -问题说明: - -- 代码使用的配置键是 `skillhub.storage.provider`。 -- `application.yml` 写的是 `skillhub.storage.type`。 -- `docker-compose.prod.yml` 也没有把 S3/MinIO 相关配置注入到后端容器。 - -影响: - -- 即使文档和运维层面按 MinIO/S3 部署,也无法通过当前配置切换到 `S3StorageService`。 -- 实际运行会默认为本地文件存储,和部署文档不一致。 - -修改建议: - -1. 统一配置键,只保留一种: - - 推荐 `skillhub.storage.provider` -2. 修正文档、`application.yml`、`docker-compose.prod.yml`、`StorageProperties` 的一致性。 -3. 增加一个启动时自检日志,打印当前启用的 storage provider。 - -### [P2] 上传大小与文件白名单配置未真正落地,当前仍是硬编码 - -问题位置: - -- `server/skillhub-app/src/main/resources/application.yml:47-64` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java:12-20` - -问题说明: - -- 配置文件声明: - - `multipart` 允许到 `100MB` - - `skillhub.publish.max-package-size` 为 `100MB` - - `allowed-file-extensions` 可配置 -- 但实际校验器仍然硬编码: - - 总包 `10MB` - - 固定扩展名集合 - -影响: - -- 文档、配置、运行时行为三者不一致。 -- 运维或产品侧修改配置后不会生效,问题定位成本高。 - -修改建议: - -1. 将 `SkillPackageValidator` 改为读取 `@ConfigurationProperties`。 -2. 保持“网关上限”“multipart 上限”“业务校验上限”三层配置含义清晰并一致。 - -### [P2] 资源不存在时多处直接 `orElseThrow()`,会把正常 404 场景放大成 500 - -问题位置: - -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java:101-111` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:60-62` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:97` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:123-132` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java:30-33` - -问题说明: - -- 多个 controller 直接使用裸 `orElseThrow()`。 -- 当资源不存在时会抛出 `NoSuchElementException`,最后被全局异常处理器转成 `500`。 -- `SkillRatingController` 还存在 `score` 缺失时的空指针风险。 - -影响: - -- API 契约不稳定,客户端会把普通业务错误当成服务故障。 - -修改建议: - -1. 统一抛出领域层 `NotFound/BadRequest` 异常。 -2. 给评分、评审、提升请求增加 `@Valid` 和显式字段校验。 - -### [P2] 前端质量门禁未闭环,lint 未通过,产物主包偏大 - -问题位置: - -- `web/src/features/skill/markdown-renderer.tsx:15-16` - -问题说明: - -- 当前 `eslint` 失败: - - `@ts-ignore` 应改为 `@ts-expect-error` - - `node` 参数未使用 -- `pnpm run build` 虽能通过,但主 JS chunk 约 `751.25 kB`,明显偏大。 - -影响: - -- CI 无法建立严格前端门禁。 -- 首屏加载与缓存更新成本偏高。 - -修改建议: - -1. 先修复 lint 报错,恢复前端静态检查门禁。 -2. 后续按页面级或功能级拆包,优先处理 markdown/highlight、管理页、上传页等非首屏模块。 - -## 4. 与设计文档的主要偏差 - -当前代码与文档的主要偏差有: - -1. `docs/05-business-flows.md` 对 Phase 2 / Phase 3 的发布状态定义,与 `SkillPublishService` 现状不一致。 -2. 文档里定义了幂等、审计、孤儿对象清理、异步事件兜底,但代码里尚未真正实现。 -3. 文档要求的权限边界比当前 review / promotion / skill GET 路由更严格,当前实现明显偏松。 -4. 部署文档强调 MinIO / S3 可切换,但配置层并未打通。 - -## 5. 现阶段是否可进入开发 - -可以继续开发,但不建议直接进入“多人并行开发 + 联调 + 准生产验证”阶段。 - -建议先完成以下最小修复集: - -1. 修复设备码认证实现与 app 测试装配。 -2. 收紧 review / promotion / skills GET 的鉴权边界。 -3. 修正发布状态机,保证 `latestVersionId` 只指向已发布版本。 -4. 修复 zip 路径校验和本地存储路径归一化。 -5. 打通前后端质量门禁:`mvn test`、`pnpm lint`、`pnpm build` 全绿。 - -在这五项完成之前,后续功能开发会持续叠加在一个不稳定基线上,返工概率较高。 diff --git "a/docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" "b/docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" deleted file mode 100644 index b8a07694..00000000 --- "a/docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" +++ /dev/null @@ -1,405 +0,0 @@ -# SkillHub 后期优化报告 - -## 1. 总体判断 - -SkillHub 当前已经具备“能跑起来的模块化单体”雏形,分层方向是对的,但距离“可持续迭代、可对外开放、可灰度发布”的工程化状态还有明显差距。 - -后续优化应分成两类: - -1. `阻塞型修复`:先把安全、鉴权、状态机、测试基线修好。 -2. `架构型收敛`:把文档、配置、协议、模块边界收敛成一个长期稳定的实现。 - -下面的建议默认以“后续要继续实际代码开发”为前提,而不是只做文档美化。 - -## 2. 优先级建议 - -### T0:1 周内必须完成 - -1. 修复设备码登录: - - 正式 token 签发 - - 原子消费 - - Redis bean / 配置注入补齐 -2. 修复 review / promotion / skills GET 的权限边界。 -3. 修复发布状态机: - - `latestVersionId` - - `SkillPublishedEvent` - - `latest` 标签 - - 搜索重建触发时机 -4. 修复上传路径安全和解压限制。 -5. 恢复质量门禁: - - `mvn test` - - `pnpm lint` - - `pnpm build` - -### T1:2 到 3 周内完成 - -1. 冻结配置协议与部署方式: - - storage provider - - S3 / MinIO - - publish 限制配置 -2. 完成评审、提升、下载、搜索的权限策略统一。 -3. 落地幂等与审计最小版。 -4. 收敛 API 路径与 DTO 契约。 - -### T2:1 到 2 个迭代完成 - -1. 引入更稳健的异步一致性方案。 -2. 做前端拆包、性能与体验优化。 -3. 增强发布安全能力和安装完整性校验。 -4. 做更强的产品化能力,例如可视化审核、统计分析、推荐等。 - -## 3. 架构优化建议 - -### 3.1 冻结发布状态机 - -当前最需要收敛的是“发布”和“审核”到底是什么关系。 - -建议明确采用下面其中一种,不要混合: - -方案 A:Phase 2 简化模型 - -- 上传成功即 `PUBLISHED` -- 不存在 `PENDING_REVIEW` -- `latestVersionId` 直接指向新版本 - -方案 B:Phase 3 审核模型 - -- 上传只创建 `DRAFT` -- 显式提交后进入 `PENDING_REVIEW` -- 审核通过后才进入 `PUBLISHED` -- 只有 `PUBLISHED` 版本能影响: - - `latestVersionId` - - `latest` 保留标签 - - 搜索索引 - - 下载入口 - -如果选择方案 B,建议新增下面两个边界对象: - -- `PublishedVersionPointer` - - 只负责“当前可安装版本” -- `DraftVersionView` - - 只负责“作者或审核人能看到的最新草稿/待审版本” - -不要再用一个 `latestVersionId` 同时表达“最新上传版本”和“最新已发布版本”。 - -### 3.2 建立统一授权层 - -当前权限判断分散在: - -- `SecurityConfig` -- Controller -- Domain Service -- `VisibilityChecker` -- `ReviewPermissionChecker` - -建议统一成三层: - -1. `Authentication` - - 只负责身份识别 -2. `Resource Access Policy` - - 只负责“谁能看” -3. `Action Authorization Policy` - - 只负责“谁能做什么” - -推荐拆出统一的 policy 组件,例如: - -- `SkillReadPolicy` -- `SkillPublishPolicy` -- `ReviewPolicy` -- `PromotionPolicy` -- `NamespacePolicy` - -Controller 不再自己拼权限,只把上下文交给 policy。 - -### 3.3 重构上传入口为单独的 Package Ingestion 模块 - -建议把当前“解压 + 校验 + 存储 + bundle 构建 + 元数据提取”从 `SkillPublishController` / `SkillPublishService` 中抽离,形成独立应用服务: - -- `SkillPackageIngestionService` - -职责建议如下: - -- 解析 zip stream -- 路径校验 -- 大小限制 -- 重复路径校验 -- 内容类型判断 -- 哈希计算 -- 生成标准化 manifest -- 输出领域对象 `ValidatedSkillPackage` - -这样好处是: - -- Web / CLI 共享同一套上传逻辑 -- 更容易做流式处理 -- 更容易接入病毒扫描、签名验证、内容审核 - -### 3.4 引入存储补偿与异步一致性机制 - -当前对象存储与数据库事务是“两套事务”,必须承认这一点。 - -建议至少补齐两层机制: - -1. 同步补偿 - - 上传过程中一旦数据库失败,立即尝试删除已写入对象 -2. 异步兜底 - - 定时扫描 orphan object - - 定时补建搜索索引 - -如果未来继续扩展,建议走标准化方案: - -- `transactional outbox` -- 后台 worker -- 幂等消费 - -### 3.5 配置体系收敛成强类型配置 - -当前配置散落在: - -- `application.yml` -- `docker-compose*.yml` -- `@ConditionalOnProperty` -- 硬编码常量 - -建议做一次统一收敛: - -- `SkillhubStorageProperties` -- `SkillhubPublishProperties` -- `SkillhubAuthProperties` -- `SkillhubRateLimitProperties` - -并要求: - -1. 所有运行时行为只从配置类读取。 -2. 业务代码禁止继续写死阈值。 -3. 启动时打印关键配置摘要,方便排障。 - -## 4. 设计优化建议 - -### 4.1 API 路径风格统一 - -当前同时存在两种风格: - -- 面向用户的 namespace/slug 坐标 -- 面向内部的数字 ID 路径 - -建议明确区分: - -- 外部公开 API:统一用 `namespace/slug/version/tag` -- 内部管理或后台 API:可以保留数字 ID - -对外协议越稳定,前端、CLI、第三方集成越容易维护。 - -### 4.2 文档与代码冻结同一份状态机和权限矩阵 - -建议补一份真正可执行的“冻结文档”,只包含这些内容: - -1. 版本状态流转表 -2. skill 可见性矩阵 -3. review / promotion 权限矩阵 -4. token scope 矩阵 -5. 公共 GET 白名单 - -这份文档应该成为: - -- 后端开发实现基准 -- 前端联调基准 -- 测试用例来源 - -### 4.3 Token 设计升级为“角色 + scope”双约束 - -当前 API Token 更像“换一种方式拿到完整用户权限”。 - -建议升级成: - -- `identity` -- `platformRoles` -- `scopes` -- `resourceConstraints` - -例如: - -- `skill:read` -- `skill:publish` -- `review:read` -- `review:approve` -- `namespace:team-a` - -并要求 Filter 只解析身份,具体权限由下游 policy 再次判断。 - -### 4.4 设备码登录设计应独立成完整子协议 - -建议把设备流单独文档化并独立实现,最少包括: - -1. device code 生命周期 -2. user code 生命周期 -3. 轮询频率限制 -4. 授权成功后的单次兑换 -5. access token / refresh token -6. 撤销与过期处理 -7. 审计字段 - -不要把它作为“先写个占位版本”长期保留在主干里。 - -## 5. 功能优化建议 - -### 5.1 发布体验 - -建议在当前基础上补齐: - -1. 上传预检接口 - - 只做包校验,不落库 -2. 发布结果详情页 - - 显示版本状态、校验结果、审计信息 -3. 版本差异展示 - - 新旧文件列表 diff -4. bundle 指纹展示 - - 便于 CLI 做一致性校验 - -### 5.2 审核体验 - -建议增加: - -1. review 队列按 namespace / 状态 / 时间过滤 -2. promotion 队列显示来源 skill 与目标 namespace -3. 审核意见模板 -4. 审核历史轨迹 - -### 5.3 安装与下载体验 - -建议补齐: - -1. 下载返回指纹 -2. CLI 安装后校验 hash -3. “latest” 与自定义 tag 的解析提示 -4. 私有 skill 访问失败时更清晰的错误码 - -### 5.4 搜索体验 - -建议按阶段演进: - -1. 先把权限过滤、排序、分页做稳定 -2. 再补关键词高亮、标签过滤、namespace 过滤 -3. 最后再考虑向量搜索或推荐 - -先把 correctness 做扎实,比过早上复杂搜索引擎更重要。 - -## 6. 创新优化建议 - -### 6.1 增加发布安全链 - -可以在上传后增加可插拔安全扫描链: - -- 文件白名单校验 -- 敏感内容扫描 -- 恶意脚本特征扫描 -- 许可证扫描 -- 依赖清单提取 - -这部分可以通过 `PrePublishValidator` 真正扩展,而不是继续 `NoOp`。 - -### 6.2 增加制品签名与可验证安装 - -建议为 bundle 增加: - -- 版本指纹 -- 服务端签名 -- CLI 验签 - -这样 SkillHub 才更像一个可被信任的官方分发源,而不只是文件托管站。 - -### 6.3 增加“来源关系”与“派生关系”图谱 - -当前 promotion 已经有 `sourceSkillId` 雏形,后面可以扩展成: - -- fork / promote / mirror / upstream - -这能支撑: - -- 技能来源追踪 -- 派生链可视化 -- 全局版与团队版差异说明 - -## 7. 迭代优化建议 - -### 7.1 推荐实施顺序 - -第一阶段:安全与正确性收口 - -- 设备码登录 -- review / promotion 鉴权 -- skills GET 白名单 -- 上传路径安全 -- latest 语义修复 - -第二阶段:工程化基线收口 - -- 测试全绿 -- 配置统一 -- 存储 provider 打通 -- 文档与代码对齐 -- 前端 lint 与拆包 - -第三阶段:产品化能力增强 - -- 审核后台 -- 审计日志 -- 幂等与回放 -- 更完整的 CLI 体验 - -第四阶段:平台化能力增强 - -- 签名安装 -- 安全扫描 -- 推荐与搜索增强 -- 可观测性与运营报表 - -### 7.2 每阶段验收标准 - -建议引入明确验收门槛: - -第一阶段验收: - -- 不再存在 P0 / P1 安全问题 -- `mvn test` 通过 -- `pnpm lint && pnpm build` 通过 - -第二阶段验收: - -- 配置切换本地/S3 存储可实测 -- 发布/审核/下载链路与文档一致 -- 私有 skill 权限边界有自动化测试 - -第三阶段验收: - -- review / promotion / audit 有可视化页面 -- CLI 对外可稳定联调 - -## 8. 建议立即补的文档 - -为了让后续开发真正顺利,建议马上追加 4 份冻结文档: - -1. `权限矩阵冻结文档` -2. `发布状态机冻结文档` -3. `上传与存储安全规范` -4. `API Token / Device Flow 协议冻结文档` - -这 4 份文档会比继续补泛化架构图更有实际开发价值。 - -## 9. 最终建议 - -SkillHub 目前最值得保留的是: - -- 模块化单体拆分方向 -- 领域服务初步分层 -- review / promotion / search / storage 的边界意识 - -最需要马上收口的是: - -- 权限模型 -- 发布状态机 -- 上传安全 -- 配置一致性 -- 测试基线 - -只要先把这五件事修稳,后面的架构优化和产品创新都能顺着推进;如果不先修,越往后叠功能,返工就越贵。 diff --git a/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md b/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md index 84b3914e..41711bc7 100644 --- a/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md +++ b/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md @@ -2756,7 +2756,7 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { // 仅对 CLI 和 Token API 路径生效 String path = request.getRequestURI(); return !(path.startsWith("/api/v1/cli/") || path.startsWith("/api/v1/tokens") - || path.startsWith("/api/compat/")); + || path.startsWith("/api/")); } } ``` @@ -2974,7 +2974,7 @@ public class SecurityConfig { .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(csrfHandler) - .ignoringRequestMatchers("/api/v1/cli/**", "/api/compat/**") + .ignoringRequestMatchers("/api/v1/cli/**", "/api/**") ) .authorizeHttpRequests(auth -> auth // 公开端点 diff --git a/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md b/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md index f49935be..ead018da 100644 --- a/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md +++ b/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md @@ -6709,7 +6709,7 @@ export function FileTree({ files, onSelect }: { import { CopyButton } from '@/shared/components/copy-button'; export function InstallCommand({ namespace, slug }: { namespace: string; slug: string }) { - const command = `skillhub install @${namespace}/${slug}`; + const command = `clawhub install @${namespace}/${slug}`; return (
{command} diff --git a/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md b/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md index 2a47bd6f..bf9548e2 100644 --- a/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md +++ b/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md @@ -6428,7 +6428,7 @@ class WellKnownControllerTest { void returns_api_base() throws Exception { mockMvc.perform(get("/.well-known/clawhub.json")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.apiBase").value("/api/compat/v1")); + .andExpect(jsonPath("$.apiBase").value("/api/v1")); } } ``` @@ -6446,7 +6446,7 @@ import java.util.Map; public class WellKnownController { @GetMapping("/.well-known/clawhub.json") public Map clawHubDiscovery() { - return Map.of("apiBase", "/api/compat/v1"); + return Map.of("apiBase", "/api/v1"); } } ``` @@ -6527,7 +6527,7 @@ class ClawHubCompatControllerTest { @Test void search_returns_compat_format() throws Exception { - mockMvc.perform(get("/api/compat/v1/search").param("q", "test")) + mockMvc.perform(get("/api/v1/search").param("q", "test")) .andExpect(status().isOk()) .andExpect(jsonPath("$.items").isArray()); } @@ -6536,14 +6536,14 @@ class ClawHubCompatControllerTest { void resolve_parses_canonical_slug() throws Exception { when(slugMapper.fromCanonical("my-skill")) .thenReturn(new SkillCoordinate("global", "my-skill")); - mockMvc.perform(get("/api/compat/v1/resolve") + mockMvc.perform(get("/api/v1/resolve") .param("slug", "my-skill")) .andExpect(status().isOk()); } @Test void whoami_requires_auth() throws Exception { - mockMvc.perform(get("/api/compat/v1/whoami")) + mockMvc.perform(get("/api/v1/whoami")) .andExpect(status().isUnauthorized()); } } @@ -6566,7 +6566,7 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; @RestController -@RequestMapping("/api/compat/v1") +@RequestMapping("/api/v1") public class ClawHubCompatController { private final CanonicalSlugMapper slugMapper; @@ -6591,7 +6591,7 @@ public class ClawHubCompatController { SkillCoordinate coord = slugMapper.fromCanonical(slug); // TODO: 调用 SkillQueryService 获取版本详情 return new ClawHubResolveResponse(slug, "", version, - "/api/compat/v1/download/" + slug + "/" + version, 0, 0); + "/api/v1/download/" + slug + "/" + version, 0, 0); } @GetMapping("/download/{slug}/{version}") diff --git a/docs/superpowers/plans/2026-03-14-dev-workflow-optimization.md b/docs/superpowers/plans/2026-03-14-dev-workflow-optimization.md new file mode 100644 index 00000000..cde70141 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-dev-workflow-optimization.md @@ -0,0 +1,625 @@ +# Dev Workflow Optimization Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Optimize the development workflow with fast-restart backend (DevTools), a hybrid Docker staging environment, and automated PR creation. + +**Architecture:** Two-stage workflow — local dev with Spring Boot DevTools for fast restarts + Vite HMR, then a hybrid staging mode that builds only the backend Docker image while mounting locally-built frontend static files into Nginx, followed by automated PR creation via `make pr`. + +**Tech Stack:** Spring Boot DevTools, Docker Compose (staging overlay), Nginx (static file mount), GNU Make, gh CLI, bash + +--- + +## Chunk 1: Spring Boot DevTools + +### Task 1: Add Spring Boot DevTools to backend + +**Files:** +- Modify: `server/skillhub-app/pom.xml` + +- [ ] **Step 1: Add DevTools dependency** + +In `server/skillhub-app/pom.xml`, add inside `` before the test dependencies: + +```xml + + org.springframework.boot + spring-boot-devtools + runtime + true + +``` + +- [ ] **Step 2: Verify DevTools is excluded from production build** + +DevTools is automatically excluded when running as a fully packaged JAR (the `optional` flag ensures it's not transitively included). The existing `spring-boot-maven-plugin` configuration in `server/skillhub-app/pom.xml` has no `` block, so the default behavior (exclude DevTools from production) applies. No changes needed. + +- [ ] **Step 3: Verify local profile still works** + +First start the dependency services, then start the backend: + +```bash +make dev +``` + +Expected: Docker services (Postgres, Redis, MinIO) start and become healthy. + +Then in the same or a new terminal: + +```bash +make dev-server +``` + +Expected: Backend starts, logs show `LiveReload server is running on port 35729` — this confirms DevTools is active. + +- [ ] **Step 4: Test fast restart** + +With backend running via `make dev-server`: +1. Edit any Java file (e.g., add a comment to a controller) +2. In IntelliJ: press `Cmd+F9` (Build Project) +3. Watch the terminal running `dev-server` + +Expected: Application restarts in 3-8 seconds (vs 30-60 seconds for a cold start). Log line: `Restarting due to classpath changes`. + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/pom.xml +git commit -m "feat(dev): add Spring Boot DevTools for fast restart in local dev" +``` + +--- + +## Chunk 2: Makefile dev-logs and dev-status targets + +### Task 2: Add dev-logs and dev-status Makefile targets + +**Files:** +- Modify: `Makefile` (will update `.PHONY` line in this chunk and again in Chunks 4 and 5 — each chunk appends new targets to the same line) + +- [ ] **Step 1: Add targets to .PHONY line** + +In `Makefile` line 1, add `dev-logs dev-status` to the `.PHONY` list after the existing targets. + +- [ ] **Step 2: Add dev-status target** + +After the `dev-all-reset` target (around line 106), add the following. Note: these targets reference variables (`DEV_PROCESS`, `DEV_SERVER_PID`, `DEV_WEB_PID`, `DEV_SERVER_LOG`, `DEV_WEB_LOG`) that are already defined at the top of the Makefile (lines 3-10). + +```makefile +dev-status: ## 查看本地开发服务状态 + @echo "=== Dependency Services ===" + @docker compose ps + @echo "" + @echo "=== Backend ===" + @if $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ + echo " Running (PID $$(cat $(DEV_SERVER_PID)))"; \ + else \ + echo " Not running"; \ + fi + @echo "=== Frontend ===" + @if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \ + echo " Running (PID $$(cat $(DEV_WEB_PID)))"; \ + else \ + echo " Not running"; \ + fi + +dev-logs: ## 实时查看开发服务日志(backend/frontend,默认 backend) + @SERVICE=$${SERVICE:-backend}; \ + if [ "$$SERVICE" = "backend" ]; then \ + tail -f $(DEV_SERVER_LOG); \ + elif [ "$$SERVICE" = "frontend" ]; then \ + tail -f $(DEV_WEB_LOG); \ + else \ + echo "Unknown service: $$SERVICE. Use SERVICE=backend or SERVICE=frontend"; \ + exit 1; \ + fi +``` + +- [ ] **Step 3: Verify targets work** + +```bash +make dev-status +``` + +Expected: Shows docker compose service status and backend/frontend process status. + +```bash +# In a separate terminal while dev-all is running: +make dev-logs +# Or for frontend: +SERVICE=frontend make dev-logs +``` + +Expected: Tails the respective log file. + +- [ ] **Step 4: Commit** + +```bash +git add Makefile +git commit -m "feat(dev): add dev-status and dev-logs make targets" +``` + +--- + +## Chunk 3: Staging Docker Compose (hybrid mode) + +### Task 3: Create docker-compose.staging.yml + +**Files:** +- Create: `docker-compose.staging.yml` + +The staging compose file uses: +- Backend: locally-built Docker image (`skillhub-server:staging`) +- Frontend: locally-built static files (`web/dist/`) mounted into an Nginx container using the existing `nginx.conf.template` +- Dependencies: same Postgres/Redis/MinIO as dev (reuses `docker-compose.yml` via `--file` flag) + +**Important:** This file references `postgres`, `redis`, and `minio` services in `depends_on` clauses, but does NOT define them. When Docker Compose is invoked with `-f docker-compose.yml -f docker-compose.staging.yml`, it merges both files, so the dependency services from `docker-compose.yml` are available to the `server` and `web` services defined here. + +- [ ] **Step 1: Create docker-compose.staging.yml** + +```yaml +# Staging environment: hybrid mode +# - Backend: locally built Docker image +# - Frontend: locally built static files mounted into Nginx +# - Dependencies: reuses docker-compose.yml (postgres, redis, minio) +# +# Usage: make staging +# Do NOT use directly — use the make target which handles build + deps + cleanup. + +services: + server: + image: skillhub-server:staging + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: docker + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub + SPRING_DATASOURCE_USERNAME: skillhub + SPRING_DATASOURCE_PASSWORD: skillhub_dev + REDIS_HOST: redis + REDIS_PORT: 6379 + SESSION_COOKIE_SECURE: "false" + SKILLHUB_PUBLIC_BASE_URL: "http://localhost" + DEVICE_AUTH_VERIFICATION_URI: "http://localhost/api/device/activate" + SKILLHUB_STORAGE_PROVIDER: s3 + STORAGE_BASE_PATH: /var/lib/skillhub/storage + SKILLHUB_STORAGE_S3_ENDPOINT: http://minio:9000 + SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT: http://localhost:9000 + SKILLHUB_STORAGE_S3_BUCKET: skillhub + SKILLHUB_STORAGE_S3_ACCESS_KEY: minioadmin + SKILLHUB_STORAGE_S3_SECRET_KEY: minioadmin + SKILLHUB_STORAGE_S3_REGION: us-east-1 + SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: "true" + SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: "true" + BOOTSTRAP_ADMIN_ENABLED: "true" + BOOTSTRAP_ADMIN_USER_ID: staging-admin + BOOTSTRAP_ADMIN_USERNAME: admin + BOOTSTRAP_ADMIN_PASSWORD: "Admin@staging2026" + BOOTSTRAP_ADMIN_DISPLAY_NAME: Admin + BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local + OAUTH2_GITHUB_CLIENT_ID: local-placeholder + OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + web: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./web/dist:/usr/share/nginx/html:ro + - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro + environment: + SKILLHUB_API_UPSTREAM: http://server:8080 + SKILLHUB_WEB_API_BASE_URL: "" + SKILLHUB_PUBLIC_BASE_URL: "" + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/nginx-health"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s +``` + +- [ ] **Step 2: Verify nginx.conf.template is compatible with nginx:alpine** + +The existing `web/nginx.conf.template` uses `${SKILLHUB_API_UPSTREAM}` variable substitution. The official `nginx:alpine` image supports this via `envsubst` when templates are placed in `/etc/nginx/templates/` — files are processed at container startup. This is already the correct path in the compose file above. + +No changes needed to `nginx.conf.template`. + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.staging.yml +git commit -m "feat(staging): add hybrid staging docker-compose" +``` + +--- + +## Chunk 4: Staging Makefile targets + +### Task 4: Add staging targets to Makefile + +**Files:** +- Modify: `Makefile` (will update `.PHONY` line again — appending to the changes from Chunk 2) + +The `make staging` command will: +1. Build backend Docker image from source +2. Build frontend static files with `pnpm build` +3. Start dependency services (reuse dev docker-compose) +4. Start staging services (backend container + nginx with mounted dist) +5. Wait for health checks +6. Run smoke tests against `http://localhost:8080` +7. Print pass/fail summary +8. On failure: print logs and exit non-zero + +**Note on Docker Compose project name:** Both `docker compose` invocations (step 3 and step 4) run from the same directory and use the same project name (default: directory name `skillhub`), so they share the same network and the `server` container can reach `postgres`/`redis`/`minio` by hostname. + +- [ ] **Step 1: Add staging variables at top of Makefile** + +After the existing `DEV_API_URL` line (line 9), add: + +```makefile +STAGING_API_URL := http://localhost:8080 +STAGING_WEB_URL := http://localhost +STAGING_SERVER_IMAGE := skillhub-server:staging +``` + +- [ ] **Step 1.5: Verify prerequisites** + +Verify that `scripts/smoke-test.sh` exists and is executable: + +```bash +ls -l scripts/smoke-test.sh +``` + +Expected: File exists and has execute permissions (`-rwxr-xr-x` or similar). + +Also verify the backend Docker build context structure: + +```bash +ls server/ +``` + +Expected output should include: `pom.xml`, `mvnw`, `.mvn/`, `skillhub-app/`, `skillhub-domain/`, `skillhub-auth/`, `skillhub-search/`, `skillhub-infra/`, `skillhub-storage/`, and `Dockerfile`. These are all required by `server/Dockerfile`. + +- [ ] **Step 2: Add staging targets to .PHONY line** + +Add `staging staging-down staging-logs` to the `.PHONY` list on line 1 (appending to the changes from Chunk 2). + +- [ ] **Step 3: Add staging targets after the dev-all-reset block** + +After `dev-all-reset` target, add: + +```makefile +staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件) + @echo "=== [1/5] Building backend Docker image ===" + docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile server + @echo "=== [2/5] Building frontend static files ===" + cd web && pnpm run build + @echo "=== [3/5] Starting dependency services ===" + docker compose up -d --wait + @echo "=== [4/5] Starting staging services ===" + docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d --wait server web + @echo "=== [5/5] Running smoke tests ===" + @if bash scripts/smoke-test.sh $(STAGING_API_URL); then \ + echo ""; \ + echo "Staging passed. Environment is running:"; \ + echo " Web UI: $(STAGING_WEB_URL)"; \ + echo " Backend: $(STAGING_API_URL)"; \ + echo ""; \ + echo "Run 'make staging-down' to stop."; \ + echo "Run 'make pr' to create a pull request."; \ + else \ + echo ""; \ + echo "Smoke tests FAILED. Printing logs..."; \ + docker compose -f docker-compose.yml -f docker-compose.staging.yml logs server; \ + $(MAKE) staging-down; \ + exit 1; \ + fi + +staging-down: ## 停止 staging 环境 + docker compose -f docker-compose.yml -f docker-compose.staging.yml down --remove-orphans + +staging-logs: ## 查看 staging 服务日志(SERVICE=server|web,默认 server) + @SERVICE=$${SERVICE:-server}; \ + docker compose -f docker-compose.yml -f docker-compose.staging.yml logs -f $$SERVICE +``` + +- [ ] **Step 4: Verify staging target syntax** + +```bash +make help +``` + +Expected: `staging`, `staging-down`, `staging-logs` appear in the help output with their descriptions. + +- [ ] **Step 5: Commit** + +```bash +git add Makefile +git commit -m "feat(staging): add staging make targets for hybrid docker regression testing" +``` + +--- + +## Chunk 5: make pr target + +### Task 5: Add make pr target to Makefile + +**Files:** +- Modify: `Makefile` (will update `.PHONY` line again — appending to the changes from Chunks 2 and 4) + +The `make pr` command will: +1. Check that `gh` CLI is installed and authenticated +2. Check for uncommitted changes and prompt to commit or abort +3. Push current branch to origin +4. Create a PR using `gh pr create` with auto-generated title from branch name and body template + +**Note:** This target uses `read -r` for interactive prompts, so it requires an interactive terminal. It will not work in CI or non-interactive contexts. + +- [ ] **Step 1: Add pr to .PHONY line** + +Add `pr` to the `.PHONY` list on line 1 (appending to the changes from Chunks 2 and 4). + +- [ ] **Step 2: Add pr target after staging-logs** + +```makefile +pr: ## 推送当前分支并创建 Pull Request(需要 gh CLI) + @if ! command -v gh >/dev/null 2>&1; then \ + echo "Error: gh CLI not found. Install from https://cli.github.com/"; \ + exit 1; \ + fi + @if ! gh auth status >/dev/null 2>&1; then \ + echo "Error: gh CLI not authenticated. Run: gh auth login"; \ + exit 1; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$BRANCH" = "main" ] || [ "$$BRANCH" = "master" ]; then \ + echo "Error: Cannot create PR from main/master branch."; \ + exit 1; \ + fi + @if ! git diff --quiet || ! git diff --cached --quiet; then \ + echo "You have uncommitted changes:"; \ + git status --short; \ + echo ""; \ + printf "Commit all changes before creating PR? [y/N] "; \ + read -r answer; \ + if [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]; then \ + git add -A; \ + git commit -m "chore: pre-PR commit"; \ + else \ + echo "Aborted. Commit or stash your changes first."; \ + exit 1; \ + fi; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + echo "Pushing branch $$BRANCH to origin..."; \ + git push -u origin "$$BRANCH" + @echo "Creating pull request..." + @if gh pr view >/dev/null 2>&1; then \ + echo "A pull request already exists for this branch:"; \ + gh pr view --json url -q '.url'; \ + exit 0; \ + fi + @gh pr create --fill --web || gh pr create --fill +``` + +Note: `--fill` auto-populates title and body from commits. `--web` opens the browser for final editing; if that fails (non-interactive), falls back to CLI creation. + +- [ ] **Step 3: Verify gh CLI is available** + +```bash +gh --version +gh auth status +``` + +Expected: gh version output and authenticated status. If not installed, note in docs. + +- [ ] **Step 4: Commit** + +```bash +git add Makefile +git commit -m "feat(dev): add make pr target for automated pull request creation" +``` + +--- + +## Chunk 6: Documentation update + +### Task 6: Update CONTRIBUTING / README with new workflow + +**Files:** +- Modify: `README.md` (development workflow section) +- Create: `docs/dev-workflow.md` + +- [ ] **Step 1: Create docs/dev-workflow.md** + +Create the file `docs/dev-workflow.md` with the following content. The inner code blocks use standard triple-backtick fences; the outer `~~~` fence here is only for plan readability: + +~~~markdown +# Development Workflow + +This document describes the recommended workflow for developing SkillHub locally. + +## Prerequisites + +- Docker Desktop (for dependency services and staging) +- Java 21 (for running the backend locally) +- Node.js 22 + pnpm (for running the frontend locally) +- `gh` CLI (for creating pull requests): https://cli.github.com/ + +## Stage 1: Local Development (fast iteration) + +Use this stage for active development — writing code, fixing bugs, iterating quickly. + +### Start the full local stack + +```bash +make dev-all +``` + +This starts: +- Dependency services (Postgres, Redis, MinIO) via Docker +- Backend (Spring Boot) directly on your machine at http://localhost:8080 +- Frontend (Vite) directly on your machine at http://localhost:3000 + +### Hot reload + +**Frontend:** Vite HMR is enabled by default. Save a file and the browser updates instantly. + +**Backend:** Spring Boot DevTools is configured. After editing Java code: +1. In IntelliJ IDEA: press `Cmd+F9` (Build Project) +2. The backend restarts automatically in 3-8 seconds +3. Watch the terminal running `make dev-server` for the restart log + +### Mock authentication + +Two mock users are available in local mode (no password needed): + +| User ID | Role | Header | +|---------------|-------------|----------------------------------| +| `local-user` | Regular user | `X-Mock-User-Id: local-user` | +| `local-admin` | Super admin | `X-Mock-User-Id: local-admin` | + +### Useful commands + +| Command | Description | +|----------------------|------------------------------------------| +| `make dev-all` | Start full local stack | +| `make dev-all-down` | Stop all local services | +| `make dev-status` | Check status of all services | +| `make dev-logs` | Tail backend logs | +| `SERVICE=frontend make dev-logs` | Tail frontend logs | +| `make dev-all-reset` | Full reset (clears data volumes) | +| `make db-reset` | Reset database only | + +## Stage 2: Staging Regression (pre-PR validation) + +Use this stage when a feature or bugfix is complete and you want to verify it works correctly in a Docker environment before pushing. + +### What staging does + +`make staging` runs a **hybrid** Docker environment: +- **Backend**: built as a Docker image from your local source +- **Frontend**: built as static files (`pnpm build`) and served by Nginx +- **Dependencies**: same Postgres/Redis/MinIO as local dev + +This is faster than building both images but still validates the containerized backend and the production Nginx serving path. + +### Run staging + +```bash +make staging +``` + +This will: +1. Build the backend Docker image +2. Build the frontend static files +3. Start all services +4. Run smoke tests against the API +5. Print pass/fail summary + +If all tests pass, the environment stays running at: +- Web UI: http://localhost +- Backend API: http://localhost:8080 + +### Stop staging + +```bash +make staging-down +``` + +### View staging logs + +```bash +make staging-logs # backend logs +SERVICE=web make staging-logs # nginx logs +``` + +## Stage 3: Create Pull Request + +After staging passes: + +```bash +make pr +``` + +This will: +1. Check for uncommitted changes (prompts to commit if any) +2. Push your branch to origin +3. Create a pull request using `gh pr create --fill` + +The PR title and body are auto-populated from your commit messages. + +> **Note:** `make pr` requires an interactive terminal. Do not use it in CI. + +## Full workflow summary + +``` +make dev-all # start local dev +# ... write code, test in browser ... +make staging # regression test in Docker +make staging-down # stop staging +make pr # push + create PR +``` +~~~ + +- [ ] **Step 2: Add workflow reference to README.md** + +In `README.md`, find the `### Local Development` section (around line 67). After the `make help` line (around line 97), add: + +```markdown +For the full development workflow (local dev → staging → PR), see [docs/dev-workflow.md](docs/dev-workflow.md). +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/dev-workflow.md README.md +git commit -m "docs: add dev-workflow guide covering local dev, staging, and PR creation" +``` + +--- + +## Final verification + +- [ ] **End-to-end test** + +```bash +# 1. Start local dev +make dev-all +# Verify: http://localhost:3000 loads, http://localhost:8080/actuator/health returns UP + +# 2. Edit a Java file, press Cmd+F9 in IntelliJ +# Verify: backend restarts in <10 seconds + +# 3. Edit a React file and save +# Verify: browser updates without full reload + +# 4. Stop local dev +make dev-all-down + +# 5. Run staging +make staging +# Verify: smoke tests pass, http://localhost loads + +# 6. Stop staging +make staging-down + +# 7. Check help output +make help +# Verify: staging, staging-down, staging-logs, dev-status, dev-logs, pr all appear +``` diff --git a/docs/superpowers/plans/2026-03-16-namespace-governance.md b/docs/superpowers/plans/2026-03-16-namespace-governance.md new file mode 100644 index 00000000..a01fb477 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-namespace-governance.md @@ -0,0 +1,520 @@ +# Namespace Governance Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the namespace governance lifecycle end-to-end: team namespace freeze/archive/restore, immutable `@global`, split management read models, state-aware backend policies, and dashboard interactions. + +**Architecture:** Extend the existing namespace domain with a dedicated governance service and a shared access-policy helper instead of overloading `NamespaceService`. Keep public and management reads separate by adding `/me/namespaces`, then thread namespace status rules through publish/review/promotion/query/search and surface them in the React dashboard with role-aware controls. + +**Tech Stack:** Spring Boot 3.x, Spring Data JPA, Spring Security, JUnit 5, Mockito, React 19, TypeScript, TanStack Query, TanStack Router, pnpm + +--- + +**Spec:** `docs/superpowers/specs/2026-03-16-namespace-governance-design.md` + +## File Structure Mapping + +### Backend domain and portal + +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java` +- Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java` +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java` +- Modify: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java` +- Modify: `server/skillhub-app/src/main/resources/messages.properties` +- Modify: `server/skillhub-app/src/main/resources/messages_zh.properties` + +### Cross-module state enforcement + +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java` + +### Frontend dashboard + +- Modify: `web/src/api/types.ts` +- Modify: `web/src/api/client.ts` +- Modify: `web/src/shared/hooks/use-skill-queries.ts` +- Modify: `web/src/pages/dashboard/my-namespaces.tsx` +- Modify: `web/src/pages/dashboard/namespace-members.tsx` +- Modify: `web/src/pages/dashboard/namespace-reviews.tsx` +- Modify: `web/src/features/namespace/namespace-header.tsx` +- Modify: `web/src/i18n/locales/zh.json` +- Modify: `web/src/i18n/locales/en.json` + +## Chunk 1: Namespace Governance Backend + +### Task 1: Add lifecycle policy and immutable-global guard + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java` +- Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java` + +- [ ] **Step 1: Write the failing domain tests** + +```java +@Test +void freezeNamespace_allowsAdminOnActiveTeamNamespace() { + Namespace namespace = namespace("team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + + Namespace updated = governanceService.freezeNamespace("team-a", "admin-1", null, null, null); + + assertEquals(NamespaceStatus.FROZEN, updated.getStatus()); +} + +@Test +void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { ... } + +@Test +void updateNamespace_rejectsFrozenNamespace() { ... } + +@Test +void addMember_rejectsArchivedNamespace() { ... } +``` + +- [ ] **Step 2: Run the domain tests to verify they fail** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=NamespaceGovernanceServiceTest,NamespaceServiceTest,NamespaceMemberServiceTest test` + +Expected: FAIL because `NamespaceGovernanceService`, namespace status setters, and read-only guards do not exist yet. + +- [ ] **Step 3: Implement the lifecycle policy** + +```java +public final class NamespaceAccessPolicy { + + public boolean isSystemImmutable(Namespace namespace) { + return namespace.getType() == NamespaceType.GLOBAL; + } + + public boolean canMutateSettings(Namespace namespace) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE; + } + + public boolean canArchive(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && role == NamespaceRole.OWNER + && namespace.getStatus() != NamespaceStatus.ARCHIVED; + } +} +``` + +```java +public Namespace freezeNamespace(String slug, String actorUserId, String requestId, String clientIp, String userAgent) { + Namespace namespace = loadMutableNamespace(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { + throw new DomainForbiddenException("error.namespace.lifecycle.freeze.forbidden"); + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid"); + } + namespace.setStatus(NamespaceStatus.FROZEN); + return namespaceRepository.save(namespace); +} +``` + +- [ ] **Step 4: Re-run the domain tests and keep them green** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=NamespaceGovernanceServiceTest,NamespaceServiceTest,NamespaceMemberServiceTest test` + +Expected: PASS for lifecycle transitions, immutable `@global`, and read-only enforcement on settings/member operations. + +- [ ] **Step 5: Commit the domain governance changes** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace +git commit -m "feat: add namespace lifecycle governance" +git push origin feature/project-namespace +``` + +### Task 2: Expose management read model and lifecycle APIs + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java` +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java` +- Modify: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java` +- Modify: `server/skillhub-app/src/main/resources/messages.properties` +- Modify: `server/skillhub-app/src/main/resources/messages_zh.properties` + +- [ ] **Step 1: Write the failing portal tests** + +```java +@Test +void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { + mockMvc.perform(get("/api/v1/me/namespaces").with(auth("owner-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")); +} + +@Test +void archiveNamespace_returnsUpdatedNamespace() throws Exception { + mockMvc.perform(post("/api/v1/namespaces/team-a/archive") + .with(csrf()) + .with(auth("owner-1")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); +} +``` + +- [ ] **Step 2: Run the portal tests to verify they fail** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=NamespacePortalControllerTest test` + +Expected: FAIL because `/me/namespaces`, lifecycle endpoints, and management DTOs do not exist. + +- [ ] **Step 3: Implement controller and DTO support** + +```java +public record MyNamespaceResponse( + Long id, + String slug, + String displayName, + NamespaceStatus status, + NamespaceType type, + NamespaceRole currentUserRole, + boolean immutable, + boolean canFreeze, + boolean canArchive, + boolean canRestore +) {} +``` + +```java +@GetMapping("/me/namespaces") +public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok("response.success.read", + namespaceService.listMyNamespaces(userId, userNsRoles != null ? userNsRoles : Map.of())); +} +``` + +- [ ] **Step 4: Re-run the portal tests** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=NamespacePortalControllerTest test` + +Expected: PASS with `currentUserRole`, lifecycle booleans, and updated namespace payloads serialized correctly. + +- [ ] **Step 5: Commit the portal API changes** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/dto \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java \ + server/skillhub-app/src/main/resources/messages.properties \ + server/skillhub-app/src/main/resources/messages_zh.properties \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +git commit -m "feat: add namespace management endpoints" +git push origin feature/project-namespace +``` + +## Chunk 2: State Enforcement Across Publish, Review, Promotion, and Public Reads + +### Task 3: Block write workflows when namespace is not ACTIVE + +**Files:** +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java` + +- [ ] **Step 1: Add failing tests for frozen and archived namespaces** + +```java +@Test +void publishFromEntries_rejectsFrozenNamespace() { ... } + +@Test +void submitReview_rejectsArchivedNamespace() throws Exception { ... } + +@Test +void submitPromotion_rejectsFrozenNamespace() throws Exception { ... } +``` + +- [ ] **Step 2: Run the workflow tests to confirm the gap** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=SkillPublishServiceTest test && ./mvnw -pl skillhub-app -Dtest=ReviewPortalControllerTest,PromotionPortalControllerTest test` + +Expected: FAIL because the publish/review/promotion flows currently ignore namespace lifecycle state. + +- [ ] **Step 3: Implement the shared ACTIVE-state guard** + +```java +private void assertNamespaceActive(Namespace namespace, String messageKey) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } +} +``` + +Apply it before: +- publish package acceptance +- review submit/approve/reject/withdraw writes +- promotion submit writes + +- [ ] **Step 4: Re-run the workflow tests** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=SkillPublishServiceTest test && ./mvnw -pl skillhub-app -Dtest=ReviewPortalControllerTest,PromotionPortalControllerTest test` + +Expected: PASS with stable error envelopes for frozen and archived namespaces. + +- [ ] **Step 5: Commit the write-path enforcement** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +git commit -m "feat: enforce namespace lifecycle on workflows" +git push origin feature/project-namespace +``` + +### Task 4: Hide archived namespaces from public skill reads and search + +**Files:** +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java` + +- [ ] **Step 1: Write the failing public-read tests** + +```java +@Test +void getSkillDetail_returnsForbiddenOrNotFoundForArchivedNamespaceToAnonymousUser() throws Exception { ... } + +@Test +void search_excludesSkillsFromArchivedNamespaces() throws Exception { ... } +``` + +- [ ] **Step 2: Run the public-read tests to verify failure** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=SkillControllerTest,SkillSearchControllerTest test` + +Expected: FAIL because archived namespace state is not filtered in skill detail or search response assembly. + +- [ ] **Step 3: Implement archived visibility filtering** + +```java +private void assertNamespaceReadable(Namespace namespace, String currentUserId, Map userNsRoles) { + boolean isMember = currentUserId != null && userNsRoles.containsKey(namespace.getId()); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isMember) { + throw new DomainForbiddenException("error.namespace.archived"); + } +} +``` + +In search assembly, drop matched skills whose namespace is archived unless the current user is a member: + +```java +.filter(skill -> namespaceVisible(skill.getNamespaceId(), userId, userNsRoles)) +``` + +- [ ] **Step 4: Re-run the public-read tests** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=SkillControllerTest,SkillSearchControllerTest test` + +Expected: PASS with archived namespaces hidden from public detail/search while frozen namespaces remain visible. + +- [ ] **Step 5: Commit the public-read visibility changes** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +git commit -m "feat: hide archived namespaces from public reads" +git push origin feature/project-namespace +``` + +## Chunk 3: Dashboard Integration + +### Task 5: Add management DTOs and mutations to the web client + +**Files:** +- Modify: `web/src/api/types.ts` +- Modify: `web/src/api/client.ts` +- Modify: `web/src/shared/hooks/use-skill-queries.ts` + +- [ ] **Step 1: Add the failing frontend type and query integration** + +Implement the client shape first so TypeScript fails until all consumers are updated: + +```ts +export interface ManagedNamespace extends Namespace { + currentUserRole: 'OWNER' | 'ADMIN' | 'MEMBER' + immutable: boolean + canFreeze: boolean + canArchive: boolean + canRestore: boolean +} +``` + +- [ ] **Step 2: Run frontend typecheck** + +Run: `cd web && pnpm typecheck` + +Expected: FAIL because `useMyNamespaces()` still returns the old `Namespace[]` shape and lifecycle mutations are missing. + +- [ ] **Step 3: Implement API helpers and hooks** + +```ts +async function getMyNamespaces(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) +} + +async function mutateNamespaceLifecycle(slug: string, action: 'freeze' | 'unfreeze' | 'archive' | 'restore', reason?: string) { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${slug}/${action}`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(reason ? { reason } : {}), + }) +} +``` + +- [ ] **Step 4: Re-run frontend typecheck** + +Run: `cd web && pnpm typecheck` + +Expected: PASS for the API layer, even though UI pages still need updates in the next task. + +- [ ] **Step 5: Commit the client-layer changes** + +```bash +git add web/src/api/types.ts web/src/api/client.ts web/src/shared/hooks/use-skill-queries.ts +git commit -m "feat: add namespace governance client hooks" +git push origin feature/project-namespace +``` + +### Task 6: Enable namespace governance in dashboard pages + +**Files:** +- Modify: `web/src/pages/dashboard/my-namespaces.tsx` +- Modify: `web/src/pages/dashboard/namespace-members.tsx` +- Modify: `web/src/pages/dashboard/namespace-reviews.tsx` +- Modify: `web/src/features/namespace/namespace-header.tsx` +- Modify: `web/src/i18n/locales/zh.json` +- Modify: `web/src/i18n/locales/en.json` + +- [ ] **Step 1: Update the pages to fail fast on missing state fields** + +Render status pills and lifecycle buttons from the new managed response shape so lint/typecheck catch any missing branch: + +```tsx +{namespace.status === 'FROZEN' ? {t('namespace.statusFrozen')} : null} +{namespace.canArchive ? : null} +``` + +- [ ] **Step 2: Run frontend validation to capture incomplete UI wiring** + +Run: `cd web && pnpm lint && pnpm typecheck` + +Expected: FAIL until the pages, translations, and mutation invalidation logic are updated consistently. + +- [ ] **Step 3: Implement the dashboard behavior** + +Apply these rules: +- `my-namespaces`: show status badge, immutable `@global` hint, role-aware lifecycle buttons +- `namespace-members`: disable add/remove/role actions when status is `FROZEN` or `ARCHIVED` +- `namespace-reviews`: keep lists visible but disable review actions when namespace is not `ACTIVE` +- `namespace-header`: show namespace status and short governance hint + +Suggested UI snippet: + +```tsx +const readOnly = namespace.status !== 'ACTIVE' || namespace.immutable + +{namespace.canFreeze ? : null} +``` + +- [ ] **Step 4: Re-run frontend validation** + +Run: `cd web && pnpm lint && pnpm typecheck` + +Expected: PASS with no TypeScript or ESLint regressions. + +- [ ] **Step 5: Commit the dashboard integration** + +```bash +git add web/src/pages/dashboard/my-namespaces.tsx \ + web/src/pages/dashboard/namespace-members.tsx \ + web/src/pages/dashboard/namespace-reviews.tsx \ + web/src/features/namespace/namespace-header.tsx \ + web/src/i18n/locales/zh.json \ + web/src/i18n/locales/en.json +git commit -m "feat: add namespace governance dashboard" +git push origin feature/project-namespace +``` + +## Final Verification + +- [ ] Run backend targeted verification: + +```bash +cd server +./mvnw -pl skillhub-domain -Dtest=NamespaceGovernanceServiceTest,NamespaceServiceTest,NamespaceMemberServiceTest,SkillPublishServiceTest test +./mvnw -pl skillhub-app -Dtest=NamespacePortalControllerTest,ReviewPortalControllerTest,PromotionPortalControllerTest,SkillControllerTest,SkillSearchControllerTest test +``` + +- [ ] Run frontend verification: + +```bash +cd web +pnpm lint +pnpm typecheck +``` + +- [ ] Run workspace status check: + +```bash +git status --short +git log --oneline -n 5 +``` + +- [ ] Push final branch state: + +```bash +git push origin feature/project-namespace +``` + +Plan complete and saved to `docs/superpowers/plans/2026-03-16-namespace-governance.md`. Ready to execute? diff --git a/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md b/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md index 08316d4d..95781606 100644 --- a/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md +++ b/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md @@ -1016,14 +1016,14 @@ GET /.well-known/clawhub.json Response 200: { - "apiBase": "/api/compat/v1" + "apiBase": "/api/v1" } ``` #### search - 搜索技能 ``` -GET /api/compat/v1/search?q=keyword&page=0&size=20 +GET /api/v1/search?q=keyword&page=0&size=20 Authorization: Bearer sk_xxx(可选) Response 200: @@ -1057,7 +1057,7 @@ Response 200: #### resolve - 解析技能版本 ``` -GET /api/compat/v1/resolve?slug=my-skill&version=1.2.0 +GET /api/v1/resolve?slug=my-skill&version=1.2.0 Authorization: Bearer sk_xxx(可选) Response 200: @@ -1065,7 +1065,7 @@ Response 200: "slug": "my-skill", "name": "My Skill", "version": "1.2.0", - "downloadUrl": "/api/compat/v1/download/my-skill/1.2.0", + "downloadUrl": "/api/v1/download/my-skill/1.2.0", "fileCount": 5, "totalSize": 12345 } @@ -1079,7 +1079,7 @@ Response 200: #### download - 下载技能包 ``` -GET /api/compat/v1/download/{slug}/{version} +GET /api/v1/download/{slug}/{version} Authorization: Bearer sk_xxx(可选) Response 200: @@ -1097,7 +1097,7 @@ Content-Disposition: attachment; filename="my-skill-1.2.0.zip" #### publish - 发布技能 ``` -POST /api/compat/v1/publish +POST /api/v1/publish Authorization: Bearer sk_xxx Content-Type: multipart/form-data @@ -1118,7 +1118,7 @@ Response 200: #### whoami - 查询当前用户 ``` -GET /api/compat/v1/whoami +GET /api/v1/whoami Authorization: Bearer sk_xxx Response 200: @@ -1135,7 +1135,7 @@ Response 200: ```java @RestController -@RequestMapping("/api/compat/v1") +@RequestMapping("/api/v1") public class ClawHubCompatController { private final CanonicalSlugMapper slugMapper; @@ -1193,7 +1193,7 @@ public class ClawHubCompatController { slug, detail.displayName(), detail.version(), - "/api/compat/v1/download/" + slug + "/" + detail.version(), + "/api/v1/download/" + slug + "/" + detail.version(), detail.fileCount(), detail.totalSize() ); diff --git a/docs/superpowers/specs/2026-03-16-namespace-governance-design.md b/docs/superpowers/specs/2026-03-16-namespace-governance-design.md new file mode 100644 index 00000000..33f98154 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-namespace-governance-design.md @@ -0,0 +1,416 @@ +# Namespace 治理补齐设计文档 + +> **Goal:** 在现有 namespace 基础能力上,补齐命名空间生命周期治理闭环。实现团队命名空间状态管理、管理台读模型拆分、前后端治理交互、跨模块状态约束、审计记录和错误语义统一。 + +> **前置条件:** Phase 2 命名空间模型、成员管理、Skill 核心链路已完成;Phase 3 审核与提升流程已接入 namespace 角色体系。 + +> **重要约束:系统内置全局空间** +> `@global` 是系统内置命名空间,不允许任何业务接口修改其基础信息、成员、状态或所有权。它只允许读取。 + +## 关键设计决策 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 治理模式 | 生命周期收敛型 | 一次性统一状态机、权限矩阵、页面行为和跨模块约束,避免零散补丁 | +| 全局空间策略 | `@global` 内置只读 | 与产品定位一致,避免把全局公共空间误当作普通团队空间治理 | +| 团队空间状态机 | `ACTIVE / FROZEN / ARCHIVED` | 已在领域模型中定义,补齐接口和行为即可 | +| 恢复语义 | `ARCHIVED -> ACTIVE` | 软归档恢复后直接回归正常运营态,避免多余状态分支 | +| 服务边界 | `NamespaceGovernanceService` 独立承载状态流转 | 避免 `NamespaceService` 混杂 CRUD、成员和生命周期逻辑 | +| 管理读模型 | 新增 `/me/namespaces` | 区分公开目录和管理台视图,支持返回冻结/归档空间 | +| 归档权限 | 团队空间仅 `OWNER` 可归档/恢复 | 归档是高风险操作,需要明确责任人 | +| 冻结权限 | 团队空间 `OWNER/ADMIN` 可冻结/解冻 | 保留日常治理能力,同时不扩大归档权限 | +| 错误暴露策略 | 归档空间对非成员公开访问按不可见处理 | 符合软归档“对外隐藏”语义 | + +## Tech Stack(沿用现有实现) + +- Backend: Spring Boot 3.x + JDK 21 + Spring Data JPA + Spring Security +- Frontend: React 19 + TypeScript + TanStack Query + TanStack Router +- Governance/Audit: 复用 `AuditLogService` + +--- + +## 1. 背景与问题 + +现有设计与实现已经具备 namespace 的基础模型、成员角色和审核边界,但仍存在以下缺口: + +1. 缺少 namespace 状态管理接口,`FROZEN / ARCHIVED` 仅停留在领域枚举层 +2. 公开空间列表与“我的命名空间”复用同一查询接口,无法呈现管理态空间 +3. 发布、审核、提升等写操作尚未统一受 namespace 状态约束 +4. 前端成员管理和治理交互处于禁用或缺失状态 +5. `@global` 的“内置只读”定位尚未在业务接口层被系统化约束 + +本设计目标是把 namespace 从“基础协作对象”提升为“完整治理对象”。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 补齐团队命名空间状态管理:冻结、解冻、归档、恢复 +- 明确 `@global` 为不可变系统空间 +- 拆分公开读模型和管理台读模型 +- 统一 namespace 状态对发布、审核、提升、公开可见性的影响 +- 补齐管理台页面交互与状态提示 +- 为状态变更增加审计记录和稳定错误语义 + +### 2.2 非目标 + +- 不新增“删除命名空间”能力 +- 不重构 skill 生命周期模型 +- 不引入新的平台后台审批流 +- 不改变现有 namespace 基础数据结构 + +## 3. 生命周期模型 + +### 3.1 命名空间类型边界 + +#### GLOBAL + +- 代表系统内置公共空间(`@global`) +- 只允许读取 +- 不允许更新基础信息 +- 不允许成员增删改 +- 不允许冻结、解冻、归档、恢复 +- 不允许转让所有权 + +#### TEAM + +- 普通团队协作空间 +- 支持完整生命周期治理 + +### 3.2 状态机 + +仅 `TEAM` 类型可发生以下流转: + +```text +ACTIVE -> FROZEN +FROZEN -> ACTIVE +ACTIVE -> ARCHIVED +FROZEN -> ARCHIVED +ARCHIVED -> ACTIVE +``` + +不支持以下流转: + +- `ARCHIVED -> FROZEN` +- 任意对 `GLOBAL` 类型的状态变更 + +### 3.3 状态语义 + +#### ACTIVE + +- 公开可见 +- 成员可管理 +- 可发布、可审核、可提升 + +#### FROZEN + +- 只读态 +- 公开内容仍可浏览和下载 +- 成员仍可查看空间详情、成员列表、审核列表 +- 禁止发布新版本 +- 禁止审核操作 +- 禁止发起提升 +- 禁止编辑命名空间信息 +- 禁止成员增删改 +- 禁止所有权转移 + +#### ARCHIVED + +- 软归档 +- 公开列表、公开搜索、公开详情默认隐藏 +- 普通用户不可下载 +- 命名空间成员仍可在管理台看到该空间 +- 除恢复外,禁止所有写操作 +- 恢复后回到 `ACTIVE` + +## 4. 权限矩阵 + +### 4.1 团队空间角色权限 + +| 操作 | OWNER | ADMIN | MEMBER | +|------|-------|-------|--------| +| 编辑空间基础信息 | `ACTIVE` 可 | `ACTIVE` 可 | 不可 | +| 添加/移除成员 | `ACTIVE` 可 | `ACTIVE` 可 | 不可 | +| 修改成员角色 | `ACTIVE` 可 | `ACTIVE` 可 | 不可 | +| 转让所有权 | `ACTIVE` 可 | 不可 | 不可 | +| 冻结 | 可 | 可 | 不可 | +| 解冻 | 可 | 可 | 不可 | +| 归档 | 可 | 不可 | 不可 | +| 恢复 | 可 | 不可 | 不可 | + +### 4.2 全局空间权限 + +`@global` 不接受任何业务写操作。无论调用者拥有哪些平台角色或 namespace 角色,都返回“系统内置命名空间不可修改”错误。 + +## 5. 后端架构设计 + +### 5.1 服务拆分 + +建议新增 `NamespaceGovernanceService`,负责所有 namespace 生命周期变更: + +- `freezeNamespace` +- `unfreezeNamespace` +- `archiveNamespace` +- `restoreNamespace` + +现有服务职责调整如下: + +- `NamespaceService` + - 创建命名空间 + - 查询 namespace + - 更新基础信息 + - 只保留基础管理员校验 +- `NamespaceMemberService` + - 成员增删改 + - 所有权转移 +- `NamespaceGovernanceService` + - 生命周期状态流转 + - `@global` 只读校验 + - 状态合法性校验 + - 审计记录 + +建议补充 `NamespaceAccessPolicy` 或同级帮助类,集中回答以下问题: + +- 当前 namespace 是否允许编辑 +- 是否允许成员管理 +- 是否允许发布 +- 是否允许审核 +- 是否允许提升 +- 是否允许公开访问 + +### 5.2 控制器设计 + +现有 [`NamespaceController`](/Users/yunzhi/Documents/skillhub/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java) 增加以下端点: + +```text +GET /api/v1/me/namespaces +POST /api/v1/namespaces/{slug}/freeze +POST /api/v1/namespaces/{slug}/unfreeze +POST /api/v1/namespaces/{slug}/archive +POST /api/v1/namespaces/{slug}/restore +``` + +Web 别名同步开放在 `/api/web/...`。 + +### 5.3 公开视图与管理视图拆分 + +#### 公开视图 + +- `GET /api/v1/namespaces` + - 仅返回 `ACTIVE` namespace +- `GET /api/v1/namespaces/{slug}` + - 匿名或普通公开访问仅可读取 `ACTIVE` + - `ARCHIVED` 对非成员按不可见处理 + +#### 管理视图 + +- `GET /api/v1/me/namespaces` + - 返回当前用户所属 namespace + - 包含 `ACTIVE / FROZEN / ARCHIVED` + - 用于“我的命名空间”页面 + +这是本次设计的关键修正:当前前端“我的命名空间”错误复用了公开 `/namespaces`,必须改为管理视图接口。 + +## 6. 跨模块业务约束 + +### 6.1 发布链路 + +在 [`SkillPublishService`](/Users/yunzhi/Documents/skillhub/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java) 中增加 namespace 状态校验: + +- `FROZEN`:拒绝发布新版本 +- `ARCHIVED`:拒绝发布新版本 + +错误语义建议区分: + +- `namespace.frozen` +- `namespace.archived` + +### 6.2 审核链路 + +审核相关写操作在 namespace 非 `ACTIVE` 时全部拒绝: + +- 提交审核 +- 审核通过 +- 审核拒绝 +- 撤回提审后再次提审 + +审核列表是否可读: + +- `FROZEN`:可读,不可写 +- `ARCHIVED`:成员可读,不可写 + +### 6.3 提升链路 + +`PromotionController` 发起提升时增加 namespace 状态校验: + +- `FROZEN`:拒绝发起 +- `ARCHIVED`:拒绝发起 + +### 6.4 公开可见性 + +#### namespace 层 + +- 公开列表只显示 `ACTIVE` +- 归档空间不进入公开目录 + +#### skill 层 + +- 若所属 namespace 为 `ARCHIVED`,公开搜索和公开详情页不再暴露该 skill +- 若所属 namespace 为 `FROZEN`,skill 仍可公开浏览和下载 + +## 7. 前端交互设计 + +涉及页面: + +- [`web/src/pages/dashboard/my-namespaces.tsx`](/Users/yunzhi/Documents/skillhub/web/src/pages/dashboard/my-namespaces.tsx) +- [`web/src/pages/dashboard/namespace-members.tsx`](/Users/yunzhi/Documents/skillhub/web/src/pages/dashboard/namespace-members.tsx) +- [`web/src/pages/dashboard/namespace-reviews.tsx`](/Users/yunzhi/Documents/skillhub/web/src/pages/dashboard/namespace-reviews.tsx) +- [`web/src/features/namespace/namespace-header.tsx`](/Users/yunzhi/Documents/skillhub/web/src/features/namespace/namespace-header.tsx) + +### 7.1 我的命名空间 + +- 数据源切换为 `GET /api/web/me/namespaces` +- 卡片展示 status badge +- 团队空间显示治理操作入口 +- `@global` 显示“系统内置,只读”提示 + +按钮可见性: + +- `OWNER` + - `ACTIVE`: 冻结、归档 + - `FROZEN`: 解冻、归档 + - `ARCHIVED`: 恢复 +- `ADMIN` + - `ACTIVE`: 冻结 + - `FROZEN`: 解冻 + - `ARCHIVED`: 无治理按钮 +- `MEMBER` + - 无治理按钮 + +### 7.2 成员管理页 + +- `ACTIVE`:允许添加成员、改角色、移除成员 +- `FROZEN / ARCHIVED`:列表仍可读,但操作按钮禁用 +- 页面顶部展示只读状态说明 + +### 7.3 审核页 + +- `ACTIVE`:正常审核 +- `FROZEN / ARCHIVED`:列表可读,审核按钮禁用 +- 页面顶部展示“当前命名空间不可处理审核任务” + +### 7.4 命名空间头部 + +[`NamespaceResponse`](/Users/yunzhi/Documents/skillhub/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java) 已包含 `status`,前端只需新增状态 badge 和说明文案,无需调整响应结构。 + +## 8. 审计与错误语义 + +### 8.1 审计动作 + +复用 `AuditLogService`,新增以下 action: + +- `FREEZE_NAMESPACE` +- `UNFREEZE_NAMESPACE` +- `ARCHIVE_NAMESPACE` +- `RESTORE_NAMESPACE` + +审计对象: + +- resourceType: `NAMESPACE` +- resourceId: namespace.id + +建议 detail 中记录: + +- `slug` +- `fromStatus` +- `toStatus` +- `reason`(可选) + +### 8.2 错误语义 + +建议统一以下错误类别: + +- `error.namespace.system.immutable` + - 对 `@global` 发起任意写操作 +- `error.namespace.state.transition.invalid` + - 非法状态流转 +- `error.namespace.frozen` + - 冻结态下执行写操作 +- `error.namespace.archived` + - 归档态下执行写操作或公开访问受限资源 + +公开访问归档空间时,对非成员优先按“不可见”处理,而不是显式暴露“已归档”。 + +## 9. 数据与接口兼容性 + +### 9.1 数据层 + +- 现有 `namespace.status` 字段已存在,无需迁移 +- 现有 `NamespaceResponse` 已带 `status` 字段,无需扩展 DTO + +### 9.2 接口层 + +- 保留现有公开 `/namespaces` +- 新增 `/me/namespaces` 供管理台使用 +- 现有前端查询需要切换,避免继续把公开目录误用为我的空间 + +### 9.3 行为层 + +- `ARCHIVED` namespace 下的 skill 公开入口行为会收紧 +- 管理台会首次出现冻结/归档空间 + +## 10. 测试策略 + +### 10.1 后端单元测试 + +- `NamespaceGovernanceServiceTest` + - 冻结/解冻/归档/恢复合法流转 + - `@global` 不可变 + - `OWNER/ADMIN/MEMBER` 权限矩阵 +- `NamespaceServiceTest` + - 冻结/归档状态下禁止基础信息更新 +- `NamespaceMemberServiceTest` + - 冻结/归档状态下禁止成员管理和所有权转移 +- `SkillPublishServiceTest` + - `FROZEN / ARCHIVED` namespace 下发布失败 +- 审核/提升相关服务测试 + - 非 `ACTIVE` namespace 下写操作失败 + +### 10.2 控制器测试 + +- `NamespaceControllerTest` + - `GET /me/namespaces` + - `POST /freeze` + - `POST /unfreeze` + - `POST /archive` + - `POST /restore` +- 公开接口测试 + - 归档空间对匿名用户不可见 + +### 10.3 前端测试 + +- 我的命名空间状态 badge 与治理按钮可见性 +- 成员页只读态 +- 审核页只读态 +- `@global` 无治理入口 + +## 11. 实施顺序建议 + +1. 后端生命周期服务与权限矩阵 +2. 跨模块状态拦截(发布、审核、提升、公开可见性) +3. `GET /me/namespaces` 管理视图接口 +4. 前端管理台接入与状态交互 +5. 审计与文档补齐 + +## 12. 风险与取舍 + +### 风险 + +- 若只改 namespace 接口、不改 skill/search/review 约束,会产生状态语义不一致 +- 若继续复用公开 `/namespaces` 作为管理台数据源,冻结/归档空间无法被恢复 + +### 取舍 + +- 本次不增加删除能力,避免把“归档”和“删除”混淆 +- 恢复统一回到 `ACTIVE`,不保留“恢复到冻结”的复杂分支 +- `@global` 完全只读,避免未来平台和团队混用治理规则 diff --git a/document/.gitignore b/document/.gitignore new file mode 100644 index 00000000..cfefc1a9 --- /dev/null +++ b/document/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +node_modules + +# Build output +build +.docusaurus +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea +.vscode +*.swp +*.swo diff --git a/document/docs/01-getting-started/_category_.json b/document/docs/01-getting-started/_category_.json new file mode 100644 index 00000000..42cea88c --- /dev/null +++ b/document/docs/01-getting-started/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "快速入门", + "position": 1, + "link": { + "type": "generated-index", + "description": "快速了解并开始使用 SkillHub" + } +} diff --git a/document/docs/01-getting-started/overview.md b/document/docs/01-getting-started/overview.md new file mode 100644 index 00000000..19df6539 --- /dev/null +++ b/document/docs/01-getting-started/overview.md @@ -0,0 +1,70 @@ +--- +title: 产品概述 +sidebar_position: 1 +description: SkillHub 产品概述和核心特性介绍 +--- + +# 产品概述 + +SkillHub 是企业级 AI 技能注册平台,支持技能发布、发现与管理,采用自托管架构保障数据安全。 + +## 核心特性 + +### 发布管理 +- 版本控制与语义化版本(Semantic Versioning) +- 自定义标签(如 `beta`/`stable`) +- `latest` 标签自动跟随最新发布版本 + +### 发现机制 +- 全文搜索 +- 多维度筛选(命名空间、下载量、评分) +- 可见性控制(公开/命名空间内/私有) + +### 组织架构 +- 命名空间隔离 +- 基于角色的访问控制(RBAC) +- 团队与全局双层空间 + +### 治理体系 +- 双层审核流程 +- 审计日志 +- 权限分离 + +### 存储与部署 +- 支持 S3/MinIO/本地存储 +- Docker/Kubernetes 部署 +- 企业级可观测性 + +## 技术栈 + +### 后端 +- **Java 21** - 运行时 +- **Spring Boot 3.2.3** - 应用框架 +- **PostgreSQL 16.x** - 主数据库 + 全文搜索 +- **Redis 7.x** - 缓存与会话存储 + +### 前端 +- **React 19** - UI 框架 +- **TypeScript** - 类型安全 +- **Vite** - 构建工具 +- **Tailwind CSS** - 样式框架 + +### 部署 +- **Docker Compose** - 单机部署 +- **Kubernetes** - 生产环境编排 + +## 核心概念 + +### 命名空间 +技能隔离边界,支持 `@global`(全局)和 `@team-*`(团队)前缀。 + +### 坐标系统 +技能标识格式为 `@{namespace_slug}/{skill_slug}`,支持语义化版本。 + +### 兼容性 +提供 REST API 和 ClawHub 兼容层,支持现有工具集成。 + +## 下一步 + +- [快速开始](./quick-start) - 一键启动体验 +- [典型应用场景](./use-cases) - 了解如何在企业中应用 diff --git a/document/docs/01-getting-started/quick-start.md b/document/docs/01-getting-started/quick-start.md new file mode 100644 index 00000000..14ebf2fa --- /dev/null +++ b/document/docs/01-getting-started/quick-start.md @@ -0,0 +1,72 @@ +--- +title: 快速开始 +sidebar_position: 2 +description: 一键启动 SkillHub 开发环境 +--- + +# 快速开始 + +## 一键启动 + +使用以下命令一键启动完整的 SkillHub 环境: + +```bash +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +``` + +或者克隆仓库后手动启动: + +```bash +git clone https://github.com/iflytek/skillhub.git +cd skillhub +make dev-all +``` + +## 访问地址 + +启动成功后,可以通过以下地址访问: + +| 服务 | 地址 | 说明 | +|------|------|------| +| Web UI | http://localhost:3000 | 前端界面 | +| Backend API | http://localhost:8080 | 后端 API | +| MinIO Console | http://localhost:9001 | 对象存储管理 | + +## 开发用户 + +本地开发环境预置了两个测试用户: + +| 用户 | 角色 | 说明 | +|------|------|------| +| `local-user` | 普通用户 | 可发布技能、管理命名空间 | +| `local-admin` | 超级管理员 | 拥有所有权限,包括审核和用户管理 | + +使用 `X-Mock-User-Id` 请求头在本地开发中模拟用户登录。 + +## 常用命令 + +```bash +# 启动完整开发环境 +make dev-all + +# 停止所有服务 +make dev-all-down + +# 重置并重新启动 +make dev-all-reset + +# 仅启动后端 +make dev + +# 仅启动前端 +make dev-web + +# 查看所有可用命令 +make help +``` + +## 下一步 + +- [产品概述](./overview) - 深入了解产品特性 +- [典型应用场景](./use-cases) - 探索企业应用场景 +- [单机部署](../administration/deployment/single-machine) - 生产环境部署指南 diff --git a/document/docs/01-getting-started/use-cases.md b/document/docs/01-getting-started/use-cases.md new file mode 100644 index 00000000..aab236c6 --- /dev/null +++ b/document/docs/01-getting-started/use-cases.md @@ -0,0 +1,72 @@ +--- +title: 典型应用场景 +sidebar_position: 3 +description: SkillHub 在企业中的典型应用场景 +--- + +# 典型应用场景 + +## 企业内部技能共享 + +**场景描述**:企业内部多个团队开发 AI 技能,需要一个集中的平台进行共享和复用。 + +**解决方案**: +- 各团队创建自己的命名空间 +- 技能在团队内先审核发布 +- 优秀技能可申请提升到全局空间 +- 所有操作有完整审计记录 + +**价值**: +- 避免重复开发 +- 促进最佳实践传播 +- 保障质量可控 + +## AI 技能治理与合规 + +**场景描述**:金融、政务等行业对 AI 应用有严格的合规要求,需要完整的审核和审计机制。 + +**解决方案**: +- 双层审核流程(团队审核 + 平台审核) +- 细粒度 RBAC 权限控制 +- 完整的操作审计日志 +- 技能版本可追溯、可撤回 + +**价值**: +- 满足合规要求 +- 风险可控 +- 责任可追溯 + +## 多团队协作开发 + +**场景描述**:大型组织中多个团队协作开发,需要清晰的权限边界和协作机制。 + +**解决方案**: +- 命名空间隔离,团队自治 +- 命名空间成员角色管理 +- 技能可见性控制(公开/命名空间内/私有) +- 团队技能可申请提升到全局 + +**价值**: +- 权责清晰 +- 协作高效 +- 安全可控 + +## CLI 工具集成 + +**场景描述**:已有使用 ClawHub CLI 的工作流,希望无缝迁移到 SkillHub。 + +**解决方案**: +- 提供 ClawHub CLI 协议兼容层 +- 通过 `/.well-known/clawhub.json` 自动发现 +- 现有 CLI 工具无需修改即可使用 +- 同时提供 SkillHub 自有 CLI 增强功能 + +**价值**: +- 保护现有投资 +- 迁移成本低 +- 渐进式升级 + +## 下一步 + +- [单机部署](../administration/deployment/single-machine) - 开始部署 +- [命名空间管理](../administration/governance/namespaces) - 了解组织治理 diff --git a/document/docs/02-administration/_category_.json b/document/docs/02-administration/_category_.json new file mode 100644 index 00000000..9e9be7e5 --- /dev/null +++ b/document/docs/02-administration/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "管理员指南", + "position": 2, + "link": { + "type": "generated-index", + "description": "部署、配置和管理 SkillHub" + } +} diff --git a/document/docs/02-administration/deployment/_category_.json b/document/docs/02-administration/deployment/_category_.json new file mode 100644 index 00000000..1b21d412 --- /dev/null +++ b/document/docs/02-administration/deployment/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "部署指南", + "position": 1 +} diff --git a/document/docs/02-administration/deployment/configuration.md b/document/docs/02-administration/deployment/configuration.md new file mode 100644 index 00000000..e89b3389 --- /dev/null +++ b/document/docs/02-administration/deployment/configuration.md @@ -0,0 +1,69 @@ +--- +title: 配置说明 +sidebar_position: 3 +description: SkillHub 配置项详细说明 +--- + +# 配置说明 + +## 环境变量 + +SkillHub 通过环境变量进行配置,主要配置项如下: + +### 基础配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `SKILLHUB_PUBLIC_BASE_URL` | 公网访问地址 | - | +| `SKILLHUB_VERSION` | 镜像版本 | `edge` | + +### 数据库配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `POSTGRES_HOST` | PostgreSQL 主机 | `postgres` | +| `POSTGRES_PORT` | PostgreSQL 端口 | `5432` | +| `POSTGRES_DB` | 数据库名 | `skillhub` | +| `POSTGRES_USER` | 数据库用户 | `skillhub` | +| `POSTGRES_PASSWORD` | 数据库密码 | - | + +### Redis 配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `REDIS_HOST` | Redis 主机 | `redis` | +| `REDIS_PORT` | Redis 端口 | `6379` | +| `REDIS_PASSWORD` | Redis 密码 | - | + +### 存储配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `SKILLHUB_STORAGE_PROVIDER` | 存储提供方 | `local` | +| `SKILLHUB_STORAGE_S3_ENDPOINT` | S3 端点 | - | +| `SKILLHUB_STORAGE_S3_BUCKET` | S3 桶名 | - | +| `SKILLHUB_STORAGE_S3_ACCESS_KEY` | S3 Access Key | - | +| `SKILLHUB_STORAGE_S3_SECRET_KEY` | S3 Secret Key | - | + +### OAuth 配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - | +| `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - | + +### 首登管理员配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` | +| `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - | +| `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - | + +## 配置文件 + +Spring Boot 配置文件位于 `server/skillhub-app/src/main/resources/`。 + +## 下一步 + +- [认证配置](../security/authentication) - 配置身份认证 diff --git a/document/docs/02-administration/deployment/kubernetes.md b/document/docs/02-administration/deployment/kubernetes.md new file mode 100644 index 00000000..a231a428 --- /dev/null +++ b/document/docs/02-administration/deployment/kubernetes.md @@ -0,0 +1,54 @@ +--- +title: Kubernetes 部署 +sidebar_position: 2 +description: 在 Kubernetes 集群中部署 SkillHub +--- + +# Kubernetes 部署 + +本文介绍如何在 Kubernetes 集群中部署 SkillHub。 + +## 前置要求 + +- Kubernetes 1.24+ +- kubectl 配置完成 +- Helm 3.0+(可选) +- 可用的持久化存储类 + +## 部署清单 + +项目提供了 Kubernetes 部署清单: + +```bash +cd deploy/k8s + +# 1. 创建命名空间 +kubectl create namespace skillhub + +# 2. 配置 Secret +cp secret.yaml.example secret.yaml +# 编辑 secret.yaml 填入真实凭证 + +# 3. 应用配置 +kubectl apply -f configmap.yaml +kubectl apply -f secret.yaml + +# 4. 部署服务 +kubectl apply -f backend-deployment.yaml +kubectl apply -f frontend-deployment.yaml +kubectl apply -f services.yaml + +# 5. 配置 Ingress +kubectl apply -f ingress.yaml +``` + +## 高可用配置 + +- 后端和前端建议至少部署 2 个副本 +- PostgreSQL 使用主从复制 +- Redis 使用 Sentinel 或 Cluster 模式 +- 存储使用高可用对象存储(如 MinIO 集群或云厂商 OSS) + +## 下一步 + +- [配置说明](./configuration) - 详细配置项说明 diff --git a/document/docs/02-administration/deployment/single-machine.md b/document/docs/02-administration/deployment/single-machine.md new file mode 100644 index 00000000..43e44d33 --- /dev/null +++ b/document/docs/02-administration/deployment/single-machine.md @@ -0,0 +1,65 @@ +--- +title: 单机部署 +sidebar_position: 1 +description: 使用 Docker Compose 单机部署 SkillHub +--- + +# 单机部署 + +本文介绍如何使用 Docker Compose 在单台服务器上部署 SkillHub。 + +## 前置要求 + +- Docker Engine 20.10+ +- Docker Compose Plugin 2.0+ +- 至少 4GB 可用内存 +- 至少 20GB 可用磁盘空间 + +## 快速部署 + +```bash +# 1. 克隆仓库 +git clone https://github.com/iflytek/skillhub.git +cd skillhub + +# 2. 复制环境变量模板 +cp .env.release.example .env.release + +# 3. 编辑配置 +# 修改 .env.release 中的配置项,特别是密码和公网地址 + +# 4. 验证配置 +make validate-release-config + +# 5. 启动服务 +docker compose --env-file .env.release -f compose.release.yml up -d +``` + +## 配置说明 + +详见 [配置说明](./configuration) 文档。 + +## 验证部署 + +```bash +# 检查容器状态 +docker compose --env-file .env.release -f compose.release.yml ps + +# 检查后端健康状态 +curl -i http://127.0.0.1:8080/actuator/health + +# 访问 Web UI +# 浏览器打开 http://localhost(或配置的公网地址) +``` + +## 首登配置 + +1. 使用 `BOOTSTRAP_ADMIN_USERNAME` 和 `BOOTSTRAP_ADMIN_PASSWORD` 登录 +2. 立即修改管理员密码 +3. 配置企业 SSO(可选) +4. 创建团队命名空间 + +## 下一步 + +- [配置说明](./configuration) - 详细配置项说明 +- [Kubernetes 部署](./kubernetes) - 高可用部署 diff --git a/document/docs/02-administration/governance/_category_.json b/document/docs/02-administration/governance/_category_.json new file mode 100644 index 00000000..f440e0fc --- /dev/null +++ b/document/docs/02-administration/governance/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "治理与运营", + "position": 3 +} diff --git a/document/docs/02-administration/governance/namespaces.md b/document/docs/02-administration/governance/namespaces.md new file mode 100644 index 00000000..2d7f92b4 --- /dev/null +++ b/document/docs/02-administration/governance/namespaces.md @@ -0,0 +1,56 @@ +--- +title: 命名空间管理 +sidebar_position: 1 +description: 命名空间创建与管理 +--- + +# 命名空间管理 + +命名空间是 SkillHub 中技能的隔离边界和协作单元。 + +## 命名空间类型 + +| 类型 | 前缀 | 说明 | +|------|------|------| +| 全局 | `@global` | 平台级公共空间,由平台管理员管理 | +| 团队 | `@team-*` | 团队/部门空间,由团队管理员管理 | + +## 创建命名空间 + +1. 登录后进入"我的命名空间" +2. 点击"创建命名空间" +3. 填写信息: + - 标识(slug):URL 友好名称 + - 显示名:展示名称 + - 描述:空间用途说明 +4. 提交创建 + +## 命名空间成员管理 + +### 添加成员 + +1. 进入命名空间设置 +2. 进入"成员管理" +3. 输入用户名搜索 +4. 选择角色(OWNER/ADMIN/MEMBER) +5. 确认添加 + +### 角色变更 + +命名空间 OWNER 或 ADMIN 可变更成员角色。 + +### 移除成员 + +命名空间 OWNER 或 ADMIN 可移除成员。 + +## 命名空间状态 + +| 状态 | 说明 | +|------|------| +| `ACTIVE` | 正常使用 | +| `FROZEN` | 冻结,只读不可发布 | +| `ARCHIVED` | 归档,对外不可见 | + +## 下一步 + +- [审核流程](./review-workflow) - 了解技能审核 diff --git a/document/docs/02-administration/governance/review-workflow.md b/document/docs/02-administration/governance/review-workflow.md new file mode 100644 index 00000000..660f8eda --- /dev/null +++ b/document/docs/02-administration/governance/review-workflow.md @@ -0,0 +1,44 @@ +--- +title: 审核流程 +sidebar_position: 2 +description: 技能发布审核流程配置 +--- + +# 审核流程 + +SkillHub 采用双层审核机制,保障技能质量。 + +## 审核流程 + +### 团队空间技能 + +1. 团队成员提交发布 +2. 创建审核任务(PENDING) +3. 团队 ADMIN 或 OWNER 审核 + - 通过 → 技能发布(PUBLISHED) + - 拒绝 → 返回修改(REJECTED) + +### 全局空间技能 + +1. 提交发布 +2. 平台 SKILL_ADMIN 或 SUPER_ADMIN 审核 +3. 审核通过后发布 + +## 团队技能提升到全局 + +1. 团队技能已发布 +2. 团队 ADMIN 或 OWNER 申请"提升到全局" +3. 平台管理员审核 +4. 审核通过后在全局空间创建新技能 + +## 审核权限 + +| 审核类型 | 所需角色 | +|---------|---------| +| 团队空间技能审核 | 命名空间 ADMIN/OWNER | +| 全局空间技能审核 | SKILL_ADMIN/SUPER_ADMIN | +| 提升申请审核 | SKILL_ADMIN/SUPER_ADMIN | + +## 下一步 + +- [用户管理](./user-management) - 管理平台用户 diff --git a/document/docs/02-administration/governance/user-management.md b/document/docs/02-administration/governance/user-management.md new file mode 100644 index 00000000..58d9abdc --- /dev/null +++ b/document/docs/02-administration/governance/user-management.md @@ -0,0 +1,61 @@ +--- +title: 用户管理 +sidebar_position: 3 +description: 平台用户管理 +--- + +# 用户管理 + +## 用户状态 + +| 状态 | 实际逻辑 | +|------|----------| +| `ACTIVE` | 可正常登录和使用系统。OAuth 首次自动准入、local 注册成功后都会进入该状态。 | +| `PENDING` | 账号已建但不可登录。OAuth 在“需要审批”策略下会创建 `PENDING` 用户并跳转到待审批页;local 登录遇到该状态会直接拒绝。 | +| `DISABLED` | 不可登录。OAuth 和 local 登录都会拒绝;`/api/v1/auth/me` 发现当前会话对应用户已被禁用时,会直接清掉 session。 | +| `MERGED` | 账号已并入其他账号,不可继续登录;主要由账号合并流程写入,不是普通用户管理流程的目标状态。 | + +## 用户准入 + +可配置新用户是否需要审批: +- 自动准入:新用户登录后自动激活 +- 审批准入:新用户需 USER_ADMIN 审批后激活 + +## 角色分配 + +`USER_ADMIN` 或 `SUPER_ADMIN` 可调用用户管理接口修改平台角色,但当前实现有几个关键点: + +- 接口一次只能设置一个目标平台角色。 +- 设置时会删除该用户已有的显式平台角色,再写入新的那个角色。 +- 如果设置为 `USER`,不会写入 `user_role_binding`,而是依赖运行时默认角色补位。 +- `USER_ADMIN` 不能分配 `SUPER_ADMIN`,只有 `SUPER_ADMIN` 能分配。 + +当前管理接口可设置的目标角色实际上是: + +- `USER` +- `SKILL_ADMIN` +- `USER_ADMIN` +- `AUDITOR` +- `SUPER_ADMIN` + +## 用户封禁/解封 + +`USER_ADMIN` 或 `SUPER_ADMIN` 可封禁/解封用户。 + +当前公开管理接口只支持把状态改成: + +- `ACTIVE` +- `DISABLED` + +其中: + +- “审批通过”本质上也是把用户状态改成 `ACTIVE`。 +- 不能通过该接口直接改成 `PENDING` 或 `MERGED`。 + +## 账号合并 + +支持将多个账号合并为一个,保留操作历史。 + +## 下一步 + +- [创建技能包](../../user-guide/publishing/create-skill) - 开始发布技能 diff --git a/document/docs/02-administration/security/_category_.json b/document/docs/02-administration/security/_category_.json new file mode 100644 index 00000000..7b64d3ab --- /dev/null +++ b/document/docs/02-administration/security/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "安全与合规", + "position": 2 +} diff --git a/document/docs/02-administration/security/audit-logs.md b/document/docs/02-administration/security/audit-logs.md new file mode 100644 index 00000000..08925fce --- /dev/null +++ b/document/docs/02-administration/security/audit-logs.md @@ -0,0 +1,38 @@ +--- +title: 审计日志 +sidebar_position: 3 +description: 操作审计日志查询与管理 +--- + +# 审计日志 + +SkillHub 记录所有关键操作的审计日志,满足企业合规要求。 + +## 审计范围 + +记录的操作包括: +- 技能发布、下载、删除 +- 审核通过、拒绝 +- 用户登录、登出 +- 权限变更 +- 命名空间管理 +- 配置变更 + +## 审计日志查询 + +通过管理后台审计日志页面或 Admin API 查询。 + +## 日志字段 + +- 操作时间 +- 操作用户 +- 操作类型 +- 目标资源 +- 客户端 IP +- User-Agent +- 请求 ID +- 详细信息 + +## 下一步 + +- [命名空间管理](../governance/namespaces) - 管理组织 diff --git a/document/docs/02-administration/security/authentication.md b/document/docs/02-administration/security/authentication.md new file mode 100644 index 00000000..923878ee --- /dev/null +++ b/document/docs/02-administration/security/authentication.md @@ -0,0 +1,36 @@ +--- +title: 认证配置 +sidebar_position: 1 +description: 配置用户认证方式 +--- + +# 认证配置 + +SkillHub 支持多种认证方式,满足不同企业的安全需求。 + +## OAuth2 登录 + +### GitHub OAuth + +1. 在 GitHub 创建 OAuth App +2. 配置环境变量: + ```bash + OAUTH2_GITHUB_CLIENT_ID=your-client-id + OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret + ``` + +### 扩展 OAuth Provider + +架构支持扩展其他 OAuth Provider,如 GitLab、Gitee 等。 + +## 本地账号登录 + +开发环境支持本地账号登录,生产环境默认关闭。 + +## 企业 SSO 集成 + +支持通过扩展点集成企业 SSO(SAML/OIDC)。 + +## 下一步 + +- [权限管理](./authorization) - 配置权限控制 diff --git a/document/docs/02-administration/security/authorization.md b/document/docs/02-administration/security/authorization.md new file mode 100644 index 00000000..36d7fef3 --- /dev/null +++ b/document/docs/02-administration/security/authorization.md @@ -0,0 +1,63 @@ +--- +title: 权限管理 +sidebar_position: 2 +description: RBAC 权限系统配置 +--- + +# 权限管理 + +SkillHub 采用基于角色的访问控制(RBAC)系统。 + +当前代码里实际存在两套并行角色体系: + +- 平台角色:控制后台治理、用户管理、审计等平台级能力。 +- 命名空间角色:控制某个团队空间内的成员、发布、审核、归档等操作。 + +二者会同时参与鉴权,但不是一套角色的上下级映射。 + +## 平台角色 + +### 代码里实际初始化的显式平台角色 + +数据库迁移只初始化了 4 个显式平台角色: + +| 角色 | 代码 | 实际能力 | +|------|------|----------| +| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion,且普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | +| 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本(yank)、处理技能举报;可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report;不能分配平台角色、不能看审计日志、不能管理用户。 | +| 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 | +| 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs` 和 `/actuator/prometheus`;治理工作台中只能看 activity,不能处理 review/promotion/report,也不能管理用户或技能。 | + +### 运行时默认平台角色 + +| 角色 | 代码 | 实际逻辑 | +|------|------|----------| +| 默认用户 | `USER` | 不是 `role` 表里的显式初始化记录。只要用户没有任何显式平台角色绑定,登录态和 `RbacService#getUserRoleCodes` 都会自动补上 `USER`。它主要表示“普通已登录用户”,没有额外后台治理权限。 | + +### 需要特别注意的实现细节 + +- 当前管理接口的“修改用户角色”是单值覆盖,不是追加:`PUT /api/v1/admin/users/{userId}/role` 先清空该用户现有平台角色,再写入一个目标角色;当目标角色是 `USER` 时,不会写数据库记录,而是依赖运行时默认补位。 +- 代码底层仍然支持“一个用户拥有多个显式平台角色”的读取与鉴权,因为 session、token 和 `RbacService` 都是按角色集合处理;只是当前管理接口不会这样分配。 +- `SUPER_ADMIN` 是唯一一个在权限查询时被视为“拥有全部 permission code”的角色,其它角色依赖 `role_permission` 关联表。 + +## 命名空间角色 + +| 角色 | 实际能力 | +|------|----------| +| `OWNER` | 创建团队空间时自动成为 `OWNER`。可更新命名空间信息、管理成员、冻结/解冻空间、归档/恢复空间、转移所有权;可提交 review;可审核团队空间 review;可访问私有技能;可管理受限技能生命周期(归档、反归档、删除草稿/驳回版本等)。 | +| `ADMIN` | 可更新命名空间信息、管理成员、冻结/解冻空间;不能归档/恢复空间,也不能直接把别人设为 `OWNER`;可提交 review;可审核团队空间 review;可访问私有技能;可管理受限技能生命周期。 | +| `MEMBER` | 默认加入全局空间时获得 `MEMBER`。可在所在命名空间发布技能、提交 review;但不能审核 review、不能管理成员、不能冻结/归档空间;私有技能也不能仅因 `MEMBER` 身份访问,私有技能要求 owner 或 `ADMIN/OWNER`。 | + +### 命名空间角色的边界 + +- `GLOBAL` 空间是只读系统空间,不能通过命名空间治理接口修改;全局空间 review/promotion/report 处理依赖平台角色 `SKILL_ADMIN`/`SUPER_ADMIN`,不是依赖全局空间成员身份。 +- `NAMESPACE_ONLY` 可见性的技能,任何该命名空间成员都能访问。 +- `PRIVATE` 可见性的技能,只有技能 owner 或命名空间 `ADMIN/OWNER` 能访问,`MEMBER` 不行。 + +## 权限配置 + +通过后台分配平台角色,通过命名空间成员关系分配命名空间角色。 + +## 下一步 + +- [审计日志](./audit-logs) - 查看操作审计 diff --git a/document/docs/03-user-guide/_category_.json b/document/docs/03-user-guide/_category_.json new file mode 100644 index 00000000..e87c7197 --- /dev/null +++ b/document/docs/03-user-guide/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "用户指南", + "position": 3, + "link": { + "type": "generated-index", + "description": "学习如何发布、发现和使用技能" + } +} diff --git a/document/docs/03-user-guide/collaboration/_category_.json b/document/docs/03-user-guide/collaboration/_category_.json new file mode 100644 index 00000000..f7661de1 --- /dev/null +++ b/document/docs/03-user-guide/collaboration/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "协作", + "position": 3 +} diff --git a/document/docs/03-user-guide/collaboration/namespaces.md b/document/docs/03-user-guide/collaboration/namespaces.md new file mode 100644 index 00000000..e737752e --- /dev/null +++ b/document/docs/03-user-guide/collaboration/namespaces.md @@ -0,0 +1,41 @@ +--- +title: 团队命名空间 +sidebar_position: 1 +description: 在团队命名空间中协作 +--- + +# 团队命名空间 + +## 加入命名空间 + +需要命名空间管理员邀请才能加入团队命名空间。 + +## 命名空间角色 + +### MEMBER +- 可查看命名空间内所有技能 +- 可发布技能(需审核) +- 可收藏和评分 + +### ADMIN +- 所有 MEMBER 权限 +- 可审核技能发布 +- 可管理成员 +- 可编辑命名空间信息 + +### OWNER +- 所有 ADMIN 权限 +- 可转让所有权 +- 可归档命名空间 + +## 技能可见性 + +| 可见性 | 说明 | +|--------|------| +| `PUBLIC` | 所有人可见,匿名可下载 | +| `NAMESPACE_ONLY` | 仅命名空间成员可见 | +| `PRIVATE` | 仅 owner 和命名空间 ADMIN 可见 | + +## 下一步 + +- [提升到全局](./promotion) - 将团队技能推广到全局 diff --git a/document/docs/03-user-guide/collaboration/promotion.md b/document/docs/03-user-guide/collaboration/promotion.md new file mode 100644 index 00000000..7d065430 --- /dev/null +++ b/document/docs/03-user-guide/collaboration/promotion.md @@ -0,0 +1,42 @@ +--- +title: 提升到全局 +sidebar_position: 2 +description: 申请将团队技能提升到全局空间 +--- + +# 提升到全局 + +优秀的团队技能可以申请提升到全局空间,供全企业使用。 + +## 提升前提 + +- 技能在团队空间已发布 +- 申请人是技能 owner 或命名空间 ADMIN +- 技能没有待审核的提升申请 + +## 申请提升 + +1. 进入团队技能详情页 +2. 点击"提升到全局" +3. 填写申请说明 +4. 提交申请 + +## 审核流程 + +1. 平台管理员收到提升申请 +2. 审核技能质量和适用性 +3. 审核通过后: + - 在全局空间创建新技能 + - 保留原团队技能 + - 记录来源追溯关系 + +## 提升后 + +- 全局空间的新技能独立管理 +- 原团队技能继续存在 +- 两者版本不自动同步 +- 如需同步需手动操作 + +## 下一步 + +- [API 概述](../../developer/api/overview) - API 集成 diff --git a/document/docs/03-user-guide/discovery/_category_.json b/document/docs/03-user-guide/discovery/_category_.json new file mode 100644 index 00000000..7014e21d --- /dev/null +++ b/document/docs/03-user-guide/discovery/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "发现与使用", + "position": 2 +} diff --git a/document/docs/03-user-guide/discovery/install.md b/document/docs/03-user-guide/discovery/install.md new file mode 100644 index 00000000..9cdf9254 --- /dev/null +++ b/document/docs/03-user-guide/discovery/install.md @@ -0,0 +1,53 @@ +--- +title: 安装使用 +sidebar_position: 2 +description: 安装和使用技能 +--- + +# 安装使用 + +## 通过 CLI 安装 + +### 安装最新版本 + +```bash +clawhub install @team/my-skill +``` + +### 安装指定版本 + +```bash +clawhub install @team/my-skill@1.2.0 +``` + +### 按标签安装 + +```bash +clawhub install @team/my-skill@beta +``` + +### 使用 ClawHub CLI 安装 + +```bash +clawhub install my-skill +clawhub install team-name--my-skill +``` + +## 安装目录 + +按以下优先级安装: + +| 优先级 | 路径 | 说明 | +|--------|------|------| +| 1 | `./.agent/skills/` | 项目级,universal 模式 | +| 2 | `~/.agent/skills/` | 全局级,universal 模式 | +| 3 | `./.claude/skills/` | 项目级,Claude 默认 | +| 4 | `~/.claude/skills/` | 全局级,Claude 默认 | + +## 在 Claude Code 中使用 + +安装后,技能会被 Claude Code 自动发现和加载。 + +## 下一步 + +- [评分与收藏](./ratings) - 反馈和收藏技能 diff --git a/document/docs/03-user-guide/discovery/ratings.md b/document/docs/03-user-guide/discovery/ratings.md new file mode 100644 index 00000000..8745a87f --- /dev/null +++ b/document/docs/03-user-guide/discovery/ratings.md @@ -0,0 +1,30 @@ +--- +title: 评分与收藏 +sidebar_position: 3 +description: 技能评分和收藏功能 +--- + +# 评分与收藏 + +## 收藏技能 + +点击技能详情页的"收藏"按钮可收藏技能。 + +查看已收藏的技能: +- Web:进入"我的收藏" +- CLI:`clawhub stars` + +## 技能评分 + +可对技能进行 1-5 分评分: + +1. 进入技能详情页 +2. 点击评分区域 +3. 选择评分(1-5 星) +4. 提交评分 + +可随时修改自己的评分。 + +## 下一步 + +- [团队命名空间](../collaboration/namespaces) - 团队协作 diff --git a/document/docs/03-user-guide/discovery/search.md b/document/docs/03-user-guide/discovery/search.md new file mode 100644 index 00000000..6a457ff9 --- /dev/null +++ b/document/docs/03-user-guide/discovery/search.md @@ -0,0 +1,35 @@ +--- +title: 搜索技能 +sidebar_position: 1 +description: 搜索和筛选技能 +--- + +# 搜索技能 + +## 全文搜索 + +在搜索框输入关键词,SkillHub 会在以下字段中搜索: +- 技能名称 +- 技能描述 +- SKILL.md 正文内容 +- 关键词 + +## 筛选条件 + +可通过以下条件筛选搜索结果: +- 命名空间 +- 可见性 +- 下载量排序 +- 评分排序 +- 更新时间排序 + +## 高级搜索 + +使用搜索语法: +- `namespace:@team-ai` - 指定命名空间 +- `category:code-review` - 指定分类 +- `downloads:>100` - 下载量大于 100 + +## 下一步 + +- [安装使用](./install) - 安装和使用技能 diff --git a/document/docs/03-user-guide/publishing/_category_.json b/document/docs/03-user-guide/publishing/_category_.json new file mode 100644 index 00000000..a8284d4a --- /dev/null +++ b/document/docs/03-user-guide/publishing/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "发布技能", + "position": 1 +} diff --git a/document/docs/03-user-guide/publishing/create-skill.md b/document/docs/03-user-guide/publishing/create-skill.md new file mode 100644 index 00000000..929d6a8d --- /dev/null +++ b/document/docs/03-user-guide/publishing/create-skill.md @@ -0,0 +1,56 @@ +--- +title: 创建技能包 +sidebar_position: 1 +description: 学习如何创建符合规范的技能包 +--- + +# 创建技能包 + +## 技能包结构 + +一个标准的 SkillHub 技能包结构如下: + +``` +my-skill/ +├── SKILL.md # 主入口文件(必需) +├── references/ # 参考资料(可选) +├── scripts/ # 脚本(可选) +└── assets/ # 静态资源(可选) +``` + +## SKILL.md 格式 + +SKILL.md 是技能包的主入口文件,使用 YAML frontmatter + Markdown 正文格式: + +```markdown +--- +name: my-skill +description: 一句话描述这个技能的用途 +x-astron-category: code-review +--- + +# 技能说明 + +这里是技能的详细说明... +``` + +### Frontmatter 字段 + +| 字段 | 必需 | 说明 | +|------|------|------| +| `name` | 是 | 技能标识,kebab-case 格式 | +| `description` | 是 | 技能简短描述 | +| `x-astron-category` | 否 | 分类标签 | +| `x-astron-runtime` | 否 | 运行时要求 | +| `x-astron-min-version` | 否 | 最低版本要求 | + +## 文件限制 + +- 单文件大小:最大 1MB +- 总包大小:最大 10MB +- 文件数量:最多 100 个 +- 允许的文件类型:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` + +## 下一步 + +- [发布流程](./publish) - 发布技能包 diff --git a/document/docs/03-user-guide/publishing/publish.md b/document/docs/03-user-guide/publishing/publish.md new file mode 100644 index 00000000..4fd86f69 --- /dev/null +++ b/document/docs/03-user-guide/publishing/publish.md @@ -0,0 +1,50 @@ +--- +title: 发布流程 +sidebar_position: 2 +description: 发布技能到 SkillHub +--- + +# 发布流程 + +## 通过 Web 发布 + +1. 登录 SkillHub +2. 点击"发布技能" +3. 选择目标命名空间 +4. 上传技能包 ZIP 文件 +5. 填写版本信息(变更日志等) +6. 提交发布 +7. 等待审核(如需要) +8. 审核通过后发布成功 + +## 通过 CLI 发布 + +```bash +# 1. 登录 +clawhub login + +# 2. 发布 +clawhub publish ./my-skill.zip --namespace @team-myteam +``` + +## 通过 ClawHub CLI 发布 + +配置 registry 后使用: + +```bash +clawhub publish ./my-skill.zip +``` + +## 发布状态 + +| 状态 | 说明 | +|------|------| +| `DRAFT` | 草稿,未提交审核 | +| `PENDING_REVIEW` | 等待审核 | +| `PUBLISHED` | 已发布,可被发现和下载 | +| `REJECTED` | 已拒绝,需修改后重新提交 | +| `YANKED` | 已撤回,不再推荐使用 | + +## 下一步 + +- [版本管理](./versioning) - 管理技能版本 diff --git a/document/docs/03-user-guide/publishing/versioning.md b/document/docs/03-user-guide/publishing/versioning.md new file mode 100644 index 00000000..a0a4d05f --- /dev/null +++ b/document/docs/03-user-guide/publishing/versioning.md @@ -0,0 +1,56 @@ +--- +title: 版本管理 +sidebar_position: 3 +description: 技能版本和标签管理 +--- + +# 版本管理 + +## 语义化版本 + +SkillHub 使用语义化版本(Semantic Versioning):`MAJOR.MINOR.PATCH` + +- `MAJOR`:不兼容的 API 变更 +- `MINOR`:向后兼容的功能新增 +- `PATCH`:向后兼容的问题修复 + +示例:`1.0.0`, `1.1.0`, `2.0.0` + +## latest 标签 + +`latest` 是系统保留标签,自动跟随最新已发布版本,不可手动移动。 + +## 自定义标签 + +可创建自定义标签用于版本通道管理: + +- `beta` - 测试版本 +- `stable` - 稳定版本 +- `stable-2026q1` - 季度稳定版本 + +### 创建/移动标签 + +```bash +clawhub tag set @team/my-skill beta 1.2.0 +``` + +### 删除标签 + +```bash +clawhub tag delete @team/my-skill beta +``` + +## 版本撤回 + +已发布版本发现问题可撤回: + +1. 进入技能详情页 +2. 找到目标版本 +3. 点击"撤回版本" +4. 确认撤回 + +撤回后的版本仍可查看,但会标记为不推荐使用。 + +## 下一步 + +- [搜索技能](../discovery/search) - 发现技能 diff --git a/document/docs/04-developer/_category_.json b/document/docs/04-developer/_category_.json new file mode 100644 index 00000000..6c210bd9 --- /dev/null +++ b/document/docs/04-developer/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "开发者参考", + "position": 4, + "link": { + "type": "generated-index", + "description": "API 参考、架构设计和扩展开发" + } +} diff --git a/document/docs/04-developer/api/_category_.json b/document/docs/04-developer/api/_category_.json new file mode 100644 index 00000000..7f8d3e0e --- /dev/null +++ b/document/docs/04-developer/api/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "API 参考", + "position": 1 +} diff --git a/document/docs/04-developer/api/authenticated.md b/document/docs/04-developer/api/authenticated.md new file mode 100644 index 00000000..3efe82dd --- /dev/null +++ b/document/docs/04-developer/api/authenticated.md @@ -0,0 +1,101 @@ +--- +title: 认证 API +sidebar_position: 3 +description: 需要认证的 API +--- + +# 认证 API + +## 认证相关 + +### 获取当前用户 + +```http +GET /api/v1/auth/me +``` + +### 登出 + +```http +POST /api/v1/auth/logout +``` + +## 技能发布 + +```http +POST /api/v1/publish +Content-Type: multipart/form-data + +file: +namespace: +``` + +## 收藏 + +```http +POST /api/v1/skills/{namespace}/{slug}/star +DELETE /api/v1/skills/{namespace}/{slug}/star +``` + +## 评分 + +```http +POST /api/v1/skills/{namespace}/{slug}/rating +Content-Type: application/json + +{ + "score": 5 +} +``` + +## 标签管理 + +```http +GET /api/v1/skills/{namespace}/{slug}/tags +PUT /api/v1/skills/{namespace}/{slug}/tags/{tagName} +DELETE /api/v1/skills/{namespace}/{slug}/tags/{tagName} +``` + +## 我的资源 + +```http +GET /api/v1/me/stars +GET /api/v1/me/skills +``` + +## 命名空间管理 + +```http +POST /api/v1/namespaces +PUT /api/v1/namespaces/{slug} +GET /api/v1/namespaces/{slug}/members +POST /api/v1/namespaces/{slug}/members +PUT /api/v1/namespaces/{slug}/members/{userId}/role +DELETE /api/v1/namespaces/{slug}/members/{userId} +``` + +## 审核 + +```http +GET /api/v1/namespaces/{slug}/reviews +POST /api/v1/namespaces/{slug}/reviews/{id}/approve +POST /api/v1/namespaces/{slug}/reviews/{id}/reject +``` + +## 提升申请 + +```http +POST /api/v1/namespaces/{slug}/skills/{skillId}/promote +``` + +## API Token + +```http +POST /api/v1/tokens +GET /api/v1/tokens +DELETE /api/v1/tokens/{id} +``` + +## 下一步 + +- [CLI 兼容层](./cli-compat) - ClawHub 兼容接口 diff --git a/document/docs/04-developer/api/cli-compat.md b/document/docs/04-developer/api/cli-compat.md new file mode 100644 index 00000000..e411a59d --- /dev/null +++ b/document/docs/04-developer/api/cli-compat.md @@ -0,0 +1,159 @@ +--- +title: CLI 兼容层 +sidebar_position: 4 +description: ClawHub CLI 协议兼容层 +--- + +# CLI 兼容层 + +SkillHub 提供 ClawHub CLI 协议兼容层,现有工具可无缝迁移。 + +## 配置 ClawHub CLI + +要让 ClawHub CLI 连接到你的 SkillHub 实例,需要配置以下环境变量: + +### 环境变量配置 + +**Linux/macOS (bash/zsh):** +```bash +# ~/.bashrc 或 ~/.zshrc +export CLAWHUB_SITE=https://skill.xfyun.cn +export CLAWHUB_REGISTRY=https://skill.xfyun.cn +``` + +**Windows (PowerShell):** +```powershell +# 永久设置(当前用户) +[Environment]::SetEnvironmentVariable('CLAWHUB_SITE', 'https://skill.xfyun.cn', 'User') +[Environment]::SetEnvironmentVariable('CLAWHUB_REGISTRY', 'https://skill.xfyun.cn', 'User') + +# 或者临时设置(当前会话) +$env:CLAWHUB_SITE = 'https://skill.xfyun.cn' +$env:CLAWHUB_REGISTRY = 'https://skill.xfyun.cn' +``` + +### 使用 CLI 标志(单次命令) + +```bash +clawhub --site https://skill.xfyun.cn --registry https://skill.xfyun.cn install +``` + +### 前端一键复制 + +SkillHub 网页端的技能详情页会自动显示带有正确环境变量的安装命令,直接复制即可使用。 + +## Well-known 发现 + +```http +GET /.well-known/clawhub.json +``` + +响应: + +```json +{ + "apiBase": "/api/v1" +} +``` + +## 兼容层 API + +### Whoami + +```http +GET /api/v1/whoami +``` + +响应: + +```json +{ + "handle": "username", + "displayName": "User Name", + "role": "user" +} +``` + +### 搜索 + +```http +GET /api/v1/search?q={keyword}&page={page}&limit={limit} +``` + +响应: + +```json +{ + "results": [ + { + "slug": "my-skill", + "name": "My Skill", + "description": "...", + "author": { + "handle": "username", + "displayName": "User Name" + }, + "version": "1.2.0", + "downloadCount": 100, + "starCount": 50, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-03-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### 解析 + +```http +GET /api/v1/resolve?slug={slug}&version={version} +``` + +响应: + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "downloadUrl": "/api/v1/download/my-skill/1.2.0" +} +``` + +### 下载 + +```http +GET /api/v1/download/{slug}/{version} +``` + +### 发布 + +```http +POST /api/v1/publish +Content-Type: multipart/form-data + +file: +``` + +响应: + +```json +{ + "slug": "my-skill", + "version": "1.0.0", + "status": "published" +} +``` + +## 坐标映射 + +| SkillHub 坐标 | ClawHub canonical slug | +|---------------|------------------------| +| `@global/my-skill` | `my-skill` | +| `@team-name/my-skill` | `team-name--my-skill` | + +## 下一步 + +- [系统架构](../architecture/overview) - 了解架构设计 diff --git a/document/docs/04-developer/api/overview.md b/document/docs/04-developer/api/overview.md new file mode 100644 index 00000000..7508d483 --- /dev/null +++ b/document/docs/04-developer/api/overview.md @@ -0,0 +1,86 @@ +--- +title: API 概述 +sidebar_position: 1 +description: SkillHub API 概述 +--- + +# API 概述 + +SkillHub 提供 RESTful API 用于集成和自动化。 + +## API 分类 + +### 公开 API +- 技能搜索 +- 技能详情 +- 版本列表 +- 下载技能 +- 无需认证(PUBLIC 技能) + +### 认证 API +- 发布技能 +- 收藏/评分 +- 命名空间管理 +- 需要登录或 Bearer Token + +### CLI 兼容层 +- 兼容 ClawHub CLI 协议 +- 现有工具可无缝迁移 + +## 响应格式 + +### 统一响应结构 + +```json +{ + "code": 0, + "msg": "成功", + "data": {}, + "timestamp": "2026-03-15T06:00:00Z", + "requestId": "req-123" +} +``` + +### 分页响应 + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "items": [], + "total": 100, + "page": 1, + "size": 20 + }, + "timestamp": "2026-03-15T06:00:00Z", + "requestId": "req-123" +} +``` + +## 认证方式 + +### Session Cookie +Web 端使用 Session Cookie 认证。 + +### Bearer Token +CLI 和 API 集成使用 Bearer Token: + +```bash +Authorization: Bearer +``` + +### API Token +可创建长期有效的 API Token 用于自动化。 + +## 幂等性 + +所有写操作支持 `X-Request-Id` 请求头实现幂等: + +```bash +X-Request-Id: +``` + +## 下一步 + +- [公开 API](./public) - 查看公开接口 diff --git a/document/docs/04-developer/api/public.md b/document/docs/04-developer/api/public.md new file mode 100644 index 00000000..e006994e --- /dev/null +++ b/document/docs/04-developer/api/public.md @@ -0,0 +1,72 @@ +--- +title: 公开 API +sidebar_position: 2 +description: 无需认证的公开 API +--- + +# 公开 API + +## 技能搜索 + +```http +GET /api/v1/skills?keyword=...&namespace=...&page=1&size=20 +``` + +**Query Parameters:** +- `keyword`: 搜索关键词 +- `namespace`: 命名空间筛选 +- `page`: 页码 +- `size`: 每页数量 + +## 技能详情 + +```http +GET /api/v1/skills/{namespace}/{slug} +``` + +## 版本列表 + +```http +GET /api/v1/skills/{namespace}/{slug}/versions +``` + +## 版本详情 + +```http +GET /api/v1/skills/{namespace}/{slug}/versions/{version} +``` + +## 文件清单 + +```http +GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files +``` + +## 下载技能 + +```http +GET /api/v1/skills/{namespace}/{slug}/download +GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download +``` + +## 解析版本 + +```http +GET /api/v1/skills/{namespace}/{slug}/resolve?version=...&tag=... +``` + +## 命名空间列表 + +```http +GET /api/v1/namespaces +``` + +## 命名空间详情 + +```http +GET /api/v1/namespaces/{slug} +``` + +## 下一步 + +- [认证 API](./authenticated) - 查看认证接口 diff --git a/document/docs/04-developer/architecture/_category_.json b/document/docs/04-developer/architecture/_category_.json new file mode 100644 index 00000000..81a75cfc --- /dev/null +++ b/document/docs/04-developer/architecture/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "架构设计", + "position": 2 +} diff --git a/document/docs/04-developer/architecture/domain-model.md b/document/docs/04-developer/architecture/domain-model.md new file mode 100644 index 00000000..1f6a4467 --- /dev/null +++ b/document/docs/04-developer/architecture/domain-model.md @@ -0,0 +1,77 @@ +--- +title: 领域模型 +sidebar_position: 2 +description: 核心领域实体和关系 +--- + +# 领域模型 + +## 核心实体 + +### Namespace(命名空间) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | bigint | 主键 | +| slug | varchar(64) | URL 友好标识 | +| display_name | varchar(128) | 展示名 | +| type | enum | `GLOBAL` / `TEAM` | +| description | text | 描述 | +| status | enum | `ACTIVE` / `FROZEN` / `ARCHIVED` | + +### NamespaceMember(命名空间成员) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | bigint | 主键 | +| namespace_id | bigint | 命名空间 ID | +| user_id | varchar(128) | 用户 ID | +| role | enum | `OWNER` / `ADMIN` / `MEMBER` | + +### Skill(技能) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | bigint | 主键 | +| namespace_id | bigint | 所属命名空间 | +| slug | varchar(128) | URL 友好标识 | +| display_name | varchar(256) | 展示名 | +| summary | varchar(512) | 摘要 | +| owner_id | varchar(128) | 主要维护人 | +| visibility | enum | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | +| status | enum | `ACTIVE` / `HIDDEN` / `ARCHIVED` | +| latest_version_id | bigint | 最新已发布版本 | + +**唯一约束**:`(namespace_id, slug)` + +### SkillVersion(技能版本) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | bigint | 主键 | +| skill_id | bigint | 技能 ID | +| version | varchar(32) | semver 版本号 | +| status | enum | `DRAFT` / `PENDING_REVIEW` / `PUBLISHED` / `REJECTED` / `YANKED` | +| manifest_json | json | 文件清单 | +| parsed_metadata_json | json | SKILL.md 解析结果 | + +**唯一约束**:`(skill_id, version)` + +### SkillTag(技能标签) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | bigint | 主键 | +| skill_id | bigint | 技能 ID | +| tag_name | varchar(64) | 标签名 | +| target_version_id | bigint | 目标版本 | + +**唯一约束**:`(skill_id, tag_name)` + +## 坐标系统 + +技能完整寻址:`@{namespace_slug}/{skill_slug}` + +## 下一步 + +- [安全架构](./security) - 安全设计 diff --git a/document/docs/04-developer/architecture/overview.md b/document/docs/04-developer/architecture/overview.md new file mode 100644 index 00000000..e00f6703 --- /dev/null +++ b/document/docs/04-developer/architecture/overview.md @@ -0,0 +1,69 @@ +--- +title: 系统架构 +sidebar_position: 1 +description: SkillHub 系统架构概览 +--- + +# 系统架构 + +## 架构原则 + +- **单体优先**:一期采用模块化单体,不拆微服务 +- **依赖倒置**:领域层不依赖基础设施 +- **可替换边界**:搜索、存储都有 SPI 抽象 + +## 模块结构 + +``` +server/ +├── skillhub-app/ # 启动、配置装配、Controller +├── skillhub-domain/ # 领域模型 + 领域服务 + 应用服务 +├── skillhub-auth/ # OAuth2 认证 + RBAC + 授权判定 +├── skillhub-search/ # 搜索 SPI + PostgreSQL 全文实现 +├── skillhub-storage/ # 对象存储抽象 + LocalFile/S3 +└── skillhub-infra/ # JPA、通用工具、配置基础 +``` + +## 模块依赖 + +``` +app → domain, auth, search, storage, infra +infra → domain +auth → domain +search → domain +storage → (独立) +``` + +## 技术栈 + +| 层级 | 技术 | 版本 | +|------|------|------| +| 运行时 | Java | 21 | +| 框架 | Spring Boot | 3.2.3 | +| 数据库 | PostgreSQL | 16.x | +| 缓存/会话 | Redis | 7.x | + +## 部署架构 + +``` +┌──────────────┐ +│ Browser / CLI│ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Web/Nginx │ +└──────┬───────┘ + │ /api/* + ▼ +┌──────────────┐ +│ Spring Boot │ +└───┬────┬─────┘ + │ │ + ▼ ▼ +PostgreSQL Redis +``` + +## 下一步 + +- [领域模型](./domain-model) - 核心实体 diff --git a/document/docs/04-developer/architecture/security.md b/document/docs/04-developer/architecture/security.md new file mode 100644 index 00000000..2280d1d9 --- /dev/null +++ b/document/docs/04-developer/architecture/security.md @@ -0,0 +1,71 @@ +--- +title: 安全架构 +sidebar_position: 3 +description: 安全架构设计 +--- + +# 安全架构 + +## 认证架构 + +### OAuth2 登录 + +- 基于 Spring Security OAuth2 Client +- 一期支持 GitHub +- 架构支持扩展多 Provider + +### CLI 认证 + +- OAuth Device Flow +- Web 授权后签发 CLI 凭证 +- 支持 API Token + +### Session 管理 + +- Spring Session + Redis +- 分布式 Session 共享 +- 支持多 Pod 部署 + +## 授权架构 + +### 平台角色 + +| 角色 | 权限 | +|------|------| +| `SUPER_ADMIN` | 所有权限 | +| `SKILL_ADMIN` | 技能治理 | +| `USER_ADMIN` | 用户治理 | +| `AUDITOR` | 审计只读 | + +### 命名空间角色 + +| 角色 | 权限 | +|------|------| +| `OWNER` | 命名空间所有者 | +| `ADMIN` | 审核、成员管理 | +| `MEMBER` | 发布技能 | + +### 可见性规则 + +| 可见性 | 谁可访问 | +|--------|---------| +| `PUBLIC` | 任何人(匿名) | +| `NAMESPACE_ONLY` | 命名空间成员 | +| `PRIVATE` | owner + 命名空间 ADMIN | + +## 审计 + +所有关键操作同步写入审计日志: +- 发布、下载、删除 +- 审核通过、拒绝 +- 权限变更 +- 配置变更 + +## 限流 + +- Ingress 层基础限流(Nginx) +- 应用层精细限流(Redis 滑动窗口) + +## 下一步 + +- [技能协议](../plugins/skill-protocol) - 技能包规范 diff --git a/document/docs/04-developer/plugins/_category_.json b/document/docs/04-developer/plugins/_category_.json new file mode 100644 index 00000000..ac3a412e --- /dev/null +++ b/document/docs/04-developer/plugins/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "扩展与集成", + "position": 3 +} diff --git a/document/docs/04-developer/plugins/skill-protocol.md b/document/docs/04-developer/plugins/skill-protocol.md new file mode 100644 index 00000000..bb136814 --- /dev/null +++ b/document/docs/04-developer/plugins/skill-protocol.md @@ -0,0 +1,68 @@ +--- +title: 技能协议 +sidebar_position: 1 +description: SKILL.md 规范和技能包协议 +--- + +# 技能协议 + +## SKILL.md 规范 + +### 基本格式 + +```markdown +--- +name: my-skill +description: When to use this skill +--- + +# Markdown 正文 + +技能指令内容... +``` + +### 必需字段 + +| 字段 | 说明 | +|------|------| +| `name` | 技能标识,kebab-case | +| `description` | 技能简短描述 | + +### 扩展字段 + +| 字段 | 说明 | +|------|------| +| `x-astron-category` | 分类标签 | +| `x-astron-runtime` | 运行时要求 | +| `x-astron-min-version` | 最低版本要求 | + +## 技能包结构 + +``` +my-skill/ +├── SKILL.md # 主入口文件(必需) +├── references/ # 参考资料(可选) +├── scripts/ # 脚本(可选) +└── assets/ # 静态资源(可选) +``` + +## 文件校验 + +- 根目录必须包含 `SKILL.md` +- 文件类型白名单 +- 单文件大小限制:1MB +- 总包大小限制:10MB +- 文件数量限制:100 个 + +## 客户端安装目录 + +按以下优先级安装: + +1. `./.agent/skills/` +2. `~/.agent/skills/` +3. `./.claude/skills/` +4. `~/.claude/skills/` + +## 下一步 + +- [存储 SPI](./storage-spi) - 扩展存储后端 diff --git a/document/docs/04-developer/plugins/storage-spi.md b/document/docs/04-developer/plugins/storage-spi.md new file mode 100644 index 00000000..f81189e1 --- /dev/null +++ b/document/docs/04-developer/plugins/storage-spi.md @@ -0,0 +1,54 @@ +--- +title: 存储 SPI +sidebar_position: 2 +description: 存储服务提供方扩展 +--- + +# 存储 SPI + +## SPI 接口 + +```java +public interface ObjectStorageService { + void store(String key, InputStream content, String contentType); + InputStream retrieve(String key); + void delete(String key); + boolean exists(String key); +} +``` + +## 内置实现 + +### LocalFileStorageService + +本地文件系统实现,用于开发环境。 + +### S3StorageService + +S3 协议兼容实现,支持: +- AWS S3 +- MinIO +- 阿里云 OSS +- 腾讯云 COS +- 其他 S3 兼容存储 + +## 配置 + +```bash +# 选择存储提供方 +SKILLHUB_STORAGE_PROVIDER=s3 + +# S3 配置 +SKILLHUB_STORAGE_S3_ENDPOINT=https://s3.example.com +SKILLHUB_STORAGE_S3_BUCKET=skillhub +SKILLHUB_STORAGE_S3_ACCESS_KEY=xxx +SKILLHUB_STORAGE_S3_SECRET_KEY=xxx +``` + +## 自定义实现 + +实现 `ObjectStorageService` 接口,注册为 Spring Bean 即可。 + +## 下一步 + +- [常见问题](../../reference/faq) - FAQ diff --git a/document/docs/05-reference/_category_.json b/document/docs/05-reference/_category_.json new file mode 100644 index 00000000..15e14d0e --- /dev/null +++ b/document/docs/05-reference/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "参考资料", + "position": 5, + "link": { + "type": "generated-index", + "description": "FAQ、故障排查和更多资源" + } +} diff --git a/document/docs/05-reference/changelog.md b/document/docs/05-reference/changelog.md new file mode 100644 index 00000000..de6f5078 --- /dev/null +++ b/document/docs/05-reference/changelog.md @@ -0,0 +1,20 @@ +--- +title: 变更日志 +sidebar_position: 3 +description: 版本变更历史 +--- + +# 变更日志 + +## [Unreleased] + +### Added +- 初始版本发布 +- 技能发布与管理 +- 命名空间与 RBAC +- 全文搜索 +- ClawHub CLI 兼容层 + +## 下一步 + +- [路线图](./roadmap) - 未来规划 diff --git a/document/docs/05-reference/faq.md b/document/docs/05-reference/faq.md new file mode 100644 index 00000000..eb2d352c --- /dev/null +++ b/document/docs/05-reference/faq.md @@ -0,0 +1,49 @@ +--- +title: 常见问题 +sidebar_position: 1 +description: 常见问题解答 +--- + +# 常见问题 + +## 部署相关 + +### 如何修改默认端口? + +修改 `.env.release` 中的端口配置。 + +### 如何配置 HTTPS? + +建议使用反向代理(Nginx/Ingress)处理 TLS 终止。 + +### 数据库如何备份? + +使用 PostgreSQL 标准备份工具(pg_dump)。 + +## 使用相关 + +### 如何重置管理员密码? + +如果忘记管理员密码,可通过环境变量重新设置首登管理员,或直接操作数据库。 + +### 技能包上传失败怎么办? + +检查: +1. 文件大小是否超限 +2. 文件类型是否在白名单内 +3. 是否包含必需的 SKILL.md +4. SKILL.md frontmatter 格式是否正确 + +## 开发相关 + +### 如何扩展 OAuth Provider? + +参考现有 GitHub 实现,添加新的 OAuth Provider 配置。 + +### 如何自定义搜索实现? + +实现 `SearchIndexService` 和 `SearchQueryService` 接口。 + +## 下一步 + +- [故障排查](./troubleshooting) - 问题诊断 diff --git a/document/docs/05-reference/roadmap.md b/document/docs/05-reference/roadmap.md new file mode 100644 index 00000000..1477856b --- /dev/null +++ b/document/docs/05-reference/roadmap.md @@ -0,0 +1,45 @@ +--- +title: 路线图 +sidebar_position: 4 +description: 未来发展路线图 +--- + +# 路线图 + +## Phase 1: 基础能力 ✅ + +- GitHub OAuth 登录 +- Session 管理 +- RBAC 权限体系 + +## Phase 2: 技能核心 ✅ + +- 命名空间管理 +- 技能发布与下载 +- 版本管理 +- PostgreSQL 全文搜索 + +## Phase 3: 审核与 CLI + +- 审核流程 +- 技能提升到全局 +- CLI 工具 +- 收藏与评分 + +## Phase 4: 运维与完善 + +- 审计日志 +- 管理后台 +- 可观测性 +- 部署优化 + +## Phase 5: 高级特性 + +- 评论与举报 +- 自动安全扫描 +- 向量搜索 +- Webhook 通知 + +## 下一步 + +- [快速开始](../getting-started/quick-start) - 开始使用 diff --git a/document/docs/05-reference/troubleshooting.md b/document/docs/05-reference/troubleshooting.md new file mode 100644 index 00000000..492d910f --- /dev/null +++ b/document/docs/05-reference/troubleshooting.md @@ -0,0 +1,63 @@ +--- +title: 故障排查 +sidebar_position: 2 +description: 常见问题诊断和解决方案 +--- + +# 故障排查 + +## 服务无法启动 + +### 检查清单 + +1. 检查容器状态:`docker compose ps` +2. 查看服务日志:`docker compose logs ` +3. 验证环境变量:检查 `.env.release` 配置 +4. 检查端口占用:`netstat -tlnp` + +### 常见原因 + +- 端口被占用 +- 数据库连接失败 +- Redis 连接失败 +- 环境变量缺失 + +## 上传失败 + +### 技能包上传失败 + +1. 检查文件大小 +2. 检查文件类型 +3. 检查 SKILL.md 格式 +4. 查看服务端日志 + +## 认证问题 + +### 无法登录 + +1. 检查 OAuth 配置 +2. 检查回调地址配置 +3. 检查 `SKILLHUB_PUBLIC_BASE_URL` 配置 + +## 性能问题 + +### 搜索慢 + +1. 检查 PostgreSQL 全文索引 +2. 考虑升级到 Elasticsearch(后续版本) + +### 下载慢 + +1. 检查对象存储配置 +2. 检查网络带宽 + +## 获取帮助 + +如以上方案无法解决问题: +1. 查看日志 +2. 提交 Issue +3. 联系技术支持 + +## 下一步 + +- [变更日志](./changelog) - 版本历史 diff --git a/document/docs/index.md b/document/docs/index.md new file mode 100644 index 00000000..648096ce --- /dev/null +++ b/document/docs/index.md @@ -0,0 +1,101 @@ +--- +title: SkillHub 文档中心 +sidebar_position: 1 +description: 企业级 AI 技能注册表 - 安全可控的技能发布、发现与管理平台 +--- + +# SkillHub + +
+
+

🏢 企业级 AI 技能注册表

+

+ 安全可控的技能发布、发现与管理平台,保障企业数据主权 +

+ +
+
+ +--- + +## 企业价值 + +
+
+
+
🔐
+

数据主权可控

+

+ 自托管部署,数据不离开企业网络;支持私有 S3/MinIO 存储;完整审计链路 +

+
+
+
+
+
🏢
+

治理体系完善

+

+ 命名空间隔离;双层审核机制;细粒度 RBAC 权限控制 +

+
+
+
+
+
🔌
+

集成能力强

+

+ 兼容 ClawHub CLI;标准 REST API;OAuth2 企业 SSO 集成 +

+
+
+
+
+
📊
+

可观测性完善

+

+ 完整审计日志;Prometheus 指标;操作追踪与溯源 +

+
+
+
+ +--- + +## 核心功能特性 + +
+
+ 版本控制 + 全文搜索 + 命名空间 + 审核流程 + 语义化版本 + 多维度筛选 + RBAC 权限 + 审计日志 +
+
+ +--- + +## 快速开始 + +
+
+ $ curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +
+

+ 访问 http://localhost:3000 开始使用 +

+
+ +--- + +## 下一步 + +- [快速开始](./getting-started/quick-start) - 一键启动 SkillHub +- [产品概述](./getting-started/overview) - 了解更多产品特性 +- [部署指南](./administration/deployment/single-machine) - 生产环境部署 diff --git a/document/docusaurus.config.js b/document/docusaurus.config.js new file mode 100644 index 00000000..c3874880 --- /dev/null +++ b/document/docusaurus.config.js @@ -0,0 +1,115 @@ +import { themes as prismThemes } from 'prism-react-renderer'; + +/** @type {import('@docusaurus/types').Config} */ +const config = { + title: 'SkillHub', + tagline: '企业级 AI 技能注册表', + favicon: 'img/favicon.ico', + + url: 'https://skillhub.iflytek.com', + baseUrl: '/', + + organizationName: 'iflytek', + projectName: 'skillhub', + + i18n: { + defaultLocale: 'zh-CN', + locales: ['zh-CN', 'en'], + localeConfigs: { + 'zh-CN': { + label: '中文', + htmlLang: 'zh-CN', + }, + 'en': { + label: 'English', + htmlLang: 'en', + }, + }, + }, + + presets: [ + [ + 'classic', + /** @type {import('@docusaurus/preset-classic').Options} */ + ({ + docs: { + routeBasePath: '/', + sidebarPath: './sidebars.js', + editUrl: 'https://github.com/iflytek/skillhub/edit/main/document/', + }, + theme: { + customCss: './src/css/custom.css', + }, + }), + ], + ], + + themeConfig: + /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ + ({ + image: 'img/og-image.png', + navbar: { + title: 'SkillHub', + logo: { + alt: 'SkillHub Logo', + src: 'img/logo.svg', + }, + items: [ + { + type: 'docSidebar', + sidebarId: 'docsSidebar', + position: 'left', + label: '文档', + }, + { + type: 'localeDropdown', + position: 'right', + }, + { + href: 'https://github.com/iflytek/skillhub', + label: 'GitHub', + position: 'right', + }, + ], + }, + footer: { + style: 'dark', + links: [ + { + title: '文档', + items: [ + { + label: '快速开始', + to: '/getting-started/quick-start', + }, + { + label: '部署指南', + to: '/administration/deployment/single-machine', + }, + { + label: 'API 参考', + to: '/developer/api/overview', + }, + ], + }, + { + title: '社区', + items: [ + { + label: 'GitHub', + href: 'https://github.com/iflytek/skillhub', + }, + ], + }, + ], + copyright: `Copyright © ${new Date().getFullYear()} iFlytek. Built with Docusaurus.`, + }, + prism: { + theme: prismThemes.github, + darkTheme: prismThemes.dracula, + additionalLanguages: ['java', 'bash', 'yaml', 'json'], + }, + }), +}; + +export default config; diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/_category_.json new file mode 100644 index 00000000..dacce6aa --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Getting Started", + "position": 1, + "link": { + "type": "generated-index", + "description": "Quickly understand and start using SkillHub" + } +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/overview.md b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/overview.md new file mode 100644 index 00000000..9bb1e577 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/overview.md @@ -0,0 +1,70 @@ +--- +title: Overview +sidebar_position: 1 +description: SkillHub product overview and core features +--- + +# Overview + +SkillHub is an enterprise-grade AI skill registry platform for publishing, discovering, and managing skills with a self-hosted architecture ensuring data security. + +## Core Features + +### Publishing Management +- Version control and Semantic Versioning +- Custom tags (like `beta`/`stable`) +- `latest` tag automatically follows the latest published version + +### Discovery +- Full-text search +- Multi-dimensional filtering (namespace, downloads, ratings) +- Visibility control + +### Organization +- Namespace isolation +- Role-based access control (RBAC) +- Team and global two-tier scopes + +### Governance +- Two-tier review workflow +- Audit logs +- Permission separation + +### Storage and Deployment +- S3/MinIO/Local storage support +- Docker/Kubernetes deployment +- Enterprise-grade observability + +## Tech Stack + +### Backend +- **Java 21** - Runtime +- **Spring Boot 3.2.3** - Application framework +- **PostgreSQL 16.x** - Primary database + full-text search +- **Redis 7.x** - Cache and session storage + +### Frontend +- **React 19** - UI framework +- **TypeScript** - Type safety +- **Vite** - Build tool +- **Tailwind CSS** - Styling framework + +### Deployment +- **Docker Compose** - Single-machine deployment +- **Kubernetes** - Production orchestration + +## Core Concepts + +### Namespace +Skill isolation boundary, supporting `@global` (global) and `@team-*` (team) prefixes. + +### Coordinate System +Skill identifier format: `@{namespace_slug}/{skill_slug}`, supports semantic versioning. + +### Compatibility +Provides REST API and ClawHub compatibility layer for existing tool integration. + +## Next Steps + +- [Quick Start](./quick-start) - One-click startup experience +- [Use Cases](./use-cases) - Explore enterprise application scenarios diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/quick-start.md b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/quick-start.md new file mode 100644 index 00000000..e751fd51 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/quick-start.md @@ -0,0 +1,72 @@ +--- +title: Quick Start +sidebar_position: 2 +description: One-click startup of SkillHub development environment +--- + +# Quick Start + +## One-click Startup + +Use the following command to start a complete SkillHub environment with one command: + +```bash +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +``` + +Or clone the repository and start manually: + +```bash +git clone https://github.com/iflytek/skillhub.git +cd skillhub +make dev-all +``` + +## Access Points + +After successful startup, you can access through the following addresses: + +| Service | Address | Description | +|---------|---------|-------------| +| Web UI | http://localhost:3000 | Frontend interface | +| Backend API | http://localhost:8080 | Backend API | +| MinIO Console | http://localhost:9001 | Object storage management | + +## Development Users + +The local development environment comes with two test users: + +| User | Role | Description | +|------|------|-------------| +| `local-user` | Regular user | Can publish skills, manage namespaces | +| `local-admin` | Super admin | Has all permissions including review and user management | + +Use the `X-Mock-User-Id` request header to simulate user login in local development. + +## Common Commands + +```bash +# Start complete development environment +make dev-all + +# Stop all services +make dev-all-down + +# Reset and restart +make dev-all-reset + +# Start backend only +make dev + +# Start frontend only +make dev-web + +# View all available commands +make help +``` + +## Next Steps + +- [Overview](./overview) - Deep dive into product features +- [Use Cases](./use-cases) - Explore enterprise application scenarios +- [Single Machine Deployment](../administration/deployment/single-machine) - Production deployment guide diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/use-cases.md b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/use-cases.md new file mode 100644 index 00000000..1611fac5 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/01-getting-started/use-cases.md @@ -0,0 +1,72 @@ +--- +title: Use Cases +sidebar_position: 3 +description: Typical enterprise application scenarios for SkillHub +--- + +# Use Cases + +## Internal Skill Sharing + +**Scenario**: Multiple teams within an enterprise develop AI skills and need a centralized platform for sharing and reuse. + +**Solution**: +- Each team creates their own namespace +- Skills are reviewed and published within the team first +- Excellent skills can be promoted to the global space +- Complete audit records for all operations + +**Value**: +- Avoid duplicate development +- Promote best practice sharing +- Ensure quality control + +## AI Skill Governance and Compliance + +**Scenario**: Industries such as finance and government have strict compliance requirements for AI applications, requiring complete review and audit mechanisms. + +**Solution**: +- Two-tier review workflow (team review + platform review) +- Fine-grained RBAC permission control +- Complete operation audit logs +- Skill version traceability and withdrawability + +**Value**: +- Meet compliance requirements +- Controllable risks +- Traceable responsibilities + +## Multi-team Collaborative Development + +**Scenario**: Large organizations with multiple teams collaborating need clear permission boundaries and collaboration mechanisms. + +**Solution**: +- Namespace isolation, team autonomy +- Namespace member role management +- Skill visibility control (public/namespace-only/private) +- Team skills can apply for promotion to global + +**Value**: +- Clear responsibilities +- Efficient collaboration +- Controllable security + +## CLI Tool Integration + +**Scenario**: Existing workflows using ClawHub CLI want to seamlessly migrate to SkillHub. + +**Solution**: +- Provide ClawHub CLI protocol compatibility layer +- Auto-discovery via `/.well-known/clawhub.json` +- Existing CLI tools work without modification +- Also provide SkillHub-native CLI enhanced features + +**Value**: +- Protect existing investments +- Low migration cost +- Gradual upgrade + +## Next Steps + +- [Single Machine Deployment](../administration/deployment/single-machine) - Start deployment +- [Namespace Management](../administration/governance/namespaces) - Learn about organization governance diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/_category_.json new file mode 100644 index 00000000..547b92e0 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Administration Guide", + "position": 2, + "link": { + "type": "generated-index", + "description": "Deploy, configure, and manage SkillHub" + } +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/_category_.json new file mode 100644 index 00000000..bcb5bc84 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Deployment Guide", + "position": 1 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md new file mode 100644 index 00000000..5e0e502b --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md @@ -0,0 +1,69 @@ +--- +title: Configuration Reference +sidebar_position: 3 +description: Detailed SkillHub configuration reference +--- + +# Configuration Reference + +## Environment Variables + +SkillHub is configured through environment variables. The main configuration items are listed below: + +### Basic Configuration + +| Environment Variable | Description | Default Value | +|---------------------|-------------|---------------| +| `SKILLHUB_PUBLIC_BASE_URL` | Public access URL | - | +| `SKILLHUB_VERSION` | Image version | `edge` | + +### Database Configuration + +| Environment Variable | Description | Default Value | +|---------------------|-------------|---------------| +| `POSTGRES_HOST` | PostgreSQL host | `postgres` | +| `POSTGRES_PORT` | PostgreSQL port | `5432` | +| `POSTGRES_DB` | Database name | `skillhub` | +| `POSTGRES_USER` | Database user | `skillhub` | +| `POSTGRES_PASSWORD` | Database password | - | + +### Redis Configuration + +| Environment Variable | Description | Default Value | +|---------------------|-------------|---------------| +| `REDIS_HOST` | Redis host | `redis` | +| `REDIS_PORT` | Redis port | `6379` | +| `REDIS_PASSWORD` | Redis password | - | + +### Storage Configuration + +| Environment Variable | Description | Default Value | +|---------------------|-------------|---------------| +| `SKILLHUB_STORAGE_PROVIDER` | Storage provider | `local` | +| `SKILLHUB_STORAGE_S3_ENDPOINT` | S3 endpoint | - | +| `SKILLHUB_STORAGE_S3_BUCKET` | S3 bucket name | - | +| `SKILLHUB_STORAGE_S3_ACCESS_KEY` | S3 Access Key | - | +| `SKILLHUB_STORAGE_S3_SECRET_KEY` | S3 Secret Key | - | + +### OAuth Configuration + +| Environment Variable | Description | Default Value | +|---------------------|-------------|---------------| +| `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - | +| `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - | + +### Bootstrap Admin Configuration + +| Environment Variable | Description | Default Value | +|---------------------|-------------|---------------| +| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `false` | +| `BOOTSTRAP_ADMIN_USERNAME` | Bootstrap admin username | - | +| `BOOTSTRAP_ADMIN_PASSWORD` | Bootstrap admin password | - | + +## Configuration Files + +Spring Boot configuration files are located at `server/skillhub-app/src/main/resources/`. + +## Next Steps + +- [Authentication Configuration](../security/authentication) - Configure authentication diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/kubernetes.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/kubernetes.md new file mode 100644 index 00000000..92941020 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/kubernetes.md @@ -0,0 +1,54 @@ +--- +title: Kubernetes Deployment +sidebar_position: 2 +description: Deploy SkillHub in a Kubernetes cluster +--- + +# Kubernetes Deployment + +This guide describes how to deploy SkillHub in a Kubernetes cluster. + +## Prerequisites + +- Kubernetes 1.24+ +- kubectl configured +- Helm 3.0+ (optional) +- Available persistent storage class + +## Deployment Manifests + +Kubernetes deployment manifests are provided in the project: + +```bash +cd deploy/k8s + +# 1. Create namespace +kubectl create namespace skillhub + +# 2. Configure Secret +cp secret.yaml.example secret.yaml +# Edit secret.yaml and fill in real credentials + +# 3. Apply configuration +kubectl apply -f configmap.yaml +kubectl apply -f secret.yaml + +# 4. Deploy services +kubectl apply -f backend-deployment.yaml +kubectl apply -f frontend-deployment.yaml +kubectl apply -f services.yaml + +# 5. Configure Ingress +kubectl apply -f ingress.yaml +``` + +## High Availability Configuration + +- Deploy at least 2 replicas for backend and frontend +- Use PostgreSQL with primary-replica replication +- Use Redis with Sentinel or Cluster mode +- Use highly available object storage (like MinIO cluster or cloud provider OSS) + +## Next Steps + +- [Configuration](./configuration) - Detailed configuration reference diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md new file mode 100644 index 00000000..2372fb26 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md @@ -0,0 +1,65 @@ +--- +title: Single Machine Deployment +sidebar_position: 1 +description: Deploy SkillHub using Docker Compose on a single machine +--- + +# Single Machine Deployment + +This guide describes how to deploy SkillHub on a single server using Docker Compose. + +## Prerequisites + +- Docker Engine 20.10+ +- Docker Compose Plugin 2.0+ +- At least 4GB available RAM +- At least 20GB available disk space + +## Quick Deployment + +```bash +# 1. Clone the repository +git clone https://github.com/iflytek/skillhub.git +cd skillhub + +# 2. Copy environment variable template +cp .env.release.example .env.release + +# 3. Edit configuration +# Modify configuration items in .env.release, especially passwords and public URLs + +# 4. Validate configuration +make validate-release-config + +# 5. Start services +docker compose --env-file .env.release -f compose.release.yml up -d +``` + +## Configuration + +See [Configuration](./configuration) documentation for details. + +## Verify Deployment + +```bash +# Check container status +docker compose --env-file .env.release -f compose.release.yml ps + +# Check backend health +curl -i http://127.0.0.1:8080/actuator/health + +# Access Web UI +# Open http://localhost in browser (or configured public URL) +``` + +## First Login Configuration + +1. Login with `BOOTSTRAP_ADMIN_USERNAME` and `BOOTSTRAP_ADMIN_PASSWORD` +2. Change admin password immediately +3. Configure enterprise SSO (optional) +4. Create team namespaces + +## Next Steps + +- [Configuration](./configuration) - Detailed configuration reference +- [Kubernetes Deployment](./kubernetes) - High availability deployment diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/_category_.json new file mode 100644 index 00000000..dec466fb --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Governance & Operations", + "position": 3 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/namespaces.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/namespaces.md new file mode 100644 index 00000000..ccc4b471 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/namespaces.md @@ -0,0 +1,56 @@ +--- +title: Namespace Management +sidebar_position: 1 +description: Namespace creation and management +--- + +# Namespace Management + +Namespaces are the isolation boundary and collaboration unit for skills in SkillHub. + +## Namespace Types + +| Type | Prefix | Description | +|------|--------|-------------| +| Global | `@global` | Platform-level public space, managed by platform admins | +| Team | `@team-*` | Team/department space, managed by team admins | + +## Create Namespace + +1. After login, go to "My Namespaces" +2. Click "Create Namespace" +3. Fill in information: + - Slug: URL-friendly name + - Display name: Display name + - Description: Space purpose description +4. Submit creation + +## Namespace Member Management + +### Add Member + +1. Go to namespace settings +2. Go to "Member Management" +3. Enter username to search +4. Select role (OWNER/ADMIN/MEMBER) +5. Confirm addition + +### Role Change + +Namespace OWNER or ADMIN can change member roles. + +### Remove Member + +Namespace OWNER or ADMIN can remove members. + +## Namespace Status + +| Status | Description | +|--------|-------------| +| `ACTIVE` | Normal use | +| `FROZEN` | Frozen, read-only, cannot publish | +| `ARCHIVED` | Archived, not visible externally | + +## Next Steps + +- [Review Workflow](./review-workflow) - Understand skill review diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/review-workflow.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/review-workflow.md new file mode 100644 index 00000000..df325059 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/review-workflow.md @@ -0,0 +1,44 @@ +--- +title: Review Workflow +sidebar_position: 2 +description: Skill publishing review workflow configuration +--- + +# Review Workflow + +SkillHub uses a two-tier review mechanism to ensure skill quality. + +## Review Workflow + +### Team Namespace Skills + +1. Team member submits publishing +2. Create review task (PENDING) +3. Team ADMIN or OWNER reviews + - Approve → Skill published (PUBLISHED) + - Reject → Return for modification (REJECTED) + +### Global Namespace Skills + +1. Submit publishing +2. Platform SKILL_ADMIN or SUPER_ADMIN reviews +3. Published after review approval + +## Promote Team Skill to Global + +1. Team skill is published +2. Team ADMIN or OWNER applies "Promote to Global" +3. Platform admin reviews +4. Creates new skill in global namespace after approval + +## Review Permissions + +| Review Type | Required Role | +|------------|---------------| +| Team namespace skill review | Namespace ADMIN/OWNER | +| Global namespace skill review | SKILL_ADMIN/SUPER_ADMIN | +| Promotion request review | SKILL_ADMIN/SUPER_ADMIN | + +## Next Steps + +- [User Management](./user-management) - Manage platform users diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md new file mode 100644 index 00000000..d6bb99d3 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md @@ -0,0 +1,61 @@ +--- +title: User Management +sidebar_position: 3 +description: Platform user management +--- + +# User Management + +## User Status + +| Status | Effective behavior | +|--------|--------------------| +| `ACTIVE` | Can log in and use the system normally. OAuth auto-admission and local registration both create users in this state. | +| `PENDING` | Account exists but cannot log in. Under approval-required OAuth flows, the system creates a `PENDING` user and redirects to the pending-approval page. Local login also rejects this status. | +| `DISABLED` | Cannot log in. Both OAuth and local auth reject it. `/api/v1/auth/me` will invalidate the current session if the backing user has been disabled. | +| `MERGED` | Account has been merged into another account and can no longer log in. This is mainly written by the account-merge flow, not by normal user administration. | + +## User Admission + +Configure whether new users require approval: +- Auto-admission: New users automatically activated after login +- Approval admission: New users require USER_ADMIN approval to activate + +## Role Assignment + +`USER_ADMIN` or `SUPER_ADMIN` can call the user-management API to change platform roles, but the implementation has a few important constraints: + +- The API sets exactly one target platform role at a time. +- It deletes the user's existing explicit platform-role bindings before writing the new one. +- If the target role is `USER`, no `user_role_binding` row is written; runtime defaulting adds it later. +- `USER_ADMIN` cannot assign `SUPER_ADMIN`; only `SUPER_ADMIN` can do that. + +The currently supported target roles in practice are: + +- `USER` +- `SKILL_ADMIN` +- `USER_ADMIN` +- `AUDITOR` +- `SUPER_ADMIN` + +## User Disable/Enable + +`USER_ADMIN` or `SUPER_ADMIN` can disable or enable users. + +The current public management API only supports changing status to: + +- `ACTIVE` +- `DISABLED` + +In practice: + +- "Approve user" is implemented as changing the status to `ACTIVE`. +- The API does not directly set users to `PENDING` or `MERGED`. + +## Account Merge + +Supports merging multiple accounts into one, preserving operation history. + +## Next Steps + +- [Create Skill Package](../../user-guide/publishing/create-skill) - Start publishing skills diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/_category_.json new file mode 100644 index 00000000..82ed3574 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Security & Compliance", + "position": 2 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/audit-logs.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/audit-logs.md new file mode 100644 index 00000000..6020c3d2 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/audit-logs.md @@ -0,0 +1,38 @@ +--- +title: Audit Logs +sidebar_position: 3 +description: Operation audit log query and management +--- + +# Audit Logs + +SkillHub records audit logs for all critical operations to meet enterprise compliance requirements. + +## Audit Scope + +Recorded operations include: +- Skill publishing, downloading, deletion +- Review approval, rejection +- User login, logout +- Permission changes +- Namespace management +- Configuration changes + +## Audit Log Query + +Query through admin dashboard audit log page or Admin API. + +## Log Fields + +- Operation time +- Operating user +- Operation type +- Target resource +- Client IP +- User-Agent +- Request ID +- Detailed information + +## Next Steps + +- [Namespace Management](../governance/namespaces) - Manage organization diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md new file mode 100644 index 00000000..148252f7 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md @@ -0,0 +1,36 @@ +--- +title: Authentication Configuration +sidebar_position: 1 +description: Configure user authentication methods +--- + +# Authentication Configuration + +SkillHub supports multiple authentication methods to meet different enterprise security requirements. + +## OAuth2 Login + +### GitHub OAuth + +1. Create an OAuth App on GitHub +2. Configure environment variables: + ```bash + OAUTH2_GITHUB_CLIENT_ID=your-client-id + OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret + ``` + +### Extend OAuth Provider + +The architecture supports extending to other OAuth providers like GitLab, Gitee, etc. + +## Local Account Login + +Local account login is supported in development environment, disabled by default in production. + +## Enterprise SSO Integration + +Supports integrating enterprise SSO (SAML/OIDC) through extension points. + +## Next Steps + +- [Authorization](./authorization) - Configure access control diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md new file mode 100644 index 00000000..4c0359be --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md @@ -0,0 +1,64 @@ +--- +title: Authorization Management +sidebar_position: 2 +description: RBAC permission system configuration +--- + +# Authorization Management + +SkillHub uses a Role-Based Access Control (RBAC) system. + +The current codebase actually uses two parallel role systems: + +- Platform roles: control platform-wide governance, user administration, and audit capabilities. +- Namespace roles: control actions inside a specific team namespace. + +They participate in authorization together, but they are not a single hierarchy. + +## Platform Roles + +### Explicit platform roles seeded by code + +The database migration seeds only 4 explicit platform roles: + +| Role | Code | Effective behavior | +|------|------|--------------------| +| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. | +| Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. | +| User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. | +| Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. | + +### Runtime default platform role + +| Role | Code | Effective behavior | +|------|------|--------------------| +| Default User | `USER` | Not an explicitly seeded row in the `role` table. If a user has no explicit platform-role binding, login/session resolution and `RbacService#getUserRoleCodes` automatically add `USER`. It represents a normal signed-in user with no extra governance privileges. | + +### Important implementation details + +- The current admin API changes platform role by replacement, not by append: `PUT /api/v1/admin/users/{userId}/role` deletes existing explicit platform-role bindings first, then writes one target role. +- If the target role is `USER`, no explicit binding is stored; the role is supplied later by runtime defaulting. +- The lower-level auth/session/RBAC code still supports multiple explicit platform roles on one user because it works with role sets. The current admin API simply does not assign roles that way. +- `SUPER_ADMIN` is the only role treated as "all permissions" during permission lookup. Other roles depend on `role_permission`. + +## Namespace Roles + +| Role | Effective behavior | +|------|--------------------| +| `OWNER` | Automatically assigned when creating a team namespace. Can update namespace settings, manage members, freeze/unfreeze the namespace, archive/restore it, and transfer ownership. Can submit reviews, review team-namespace review tasks, access private skills, and manage restricted skill lifecycle. | +| `ADMIN` | Can update namespace settings, manage members, and freeze/unfreeze the namespace. Cannot archive/restore the namespace and cannot directly set someone to `OWNER`. Can submit reviews, review team-namespace review tasks, access private skills, and manage restricted skill lifecycle. | +| `MEMBER` | Common member role, including auto-membership in the global namespace. Can publish skills in namespaces they belong to and can submit reviews. Cannot review review tasks, manage members, or freeze/archive namespaces. `MEMBER` alone is not enough to access private skills. | + +### Namespace role boundaries + +- The `GLOBAL` namespace is effectively immutable in namespace-governance flows. Review/promotion/report handling there depends on platform roles `SKILL_ADMIN` / `SUPER_ADMIN`, not on global namespace membership alone. +- For `NAMESPACE_ONLY` visibility, any namespace member can access the skill. +- For `PRIVATE` visibility, access is limited to the skill owner or namespace `ADMIN` / `OWNER`; `MEMBER` is not enough. + +## Permission Configuration + +Assign platform roles through admin user management, and namespace roles through namespace membership. + +## Next Steps + +- [Audit Logs](./audit-logs) - View operation audits diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/_category_.json new file mode 100644 index 00000000..2fa27ec7 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "User Guide", + "position": 3, + "link": { + "type": "generated-index", + "description": "Learn how to publish, discover, and use skills" + } +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/_category_.json new file mode 100644 index 00000000..6edb05d5 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Collaboration", + "position": 3 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/namespaces.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/namespaces.md new file mode 100644 index 00000000..079591da --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/namespaces.md @@ -0,0 +1,41 @@ +--- +title: Team Namespaces +sidebar_position: 1 +description: Collaborate in team namespaces +--- + +# Team Namespaces + +## Join Namespace + +Requires namespace admin invitation to join a team namespace. + +## Namespace Roles + +### MEMBER +- Can view all skills in namespace +- Can publish skills (requires review) +- Can favorite and rate + +### ADMIN +- All MEMBER permissions +- Can review skill publishing +- Can manage members +- Can edit namespace information + +### OWNER +- All ADMIN permissions +- Can transfer ownership +- Can archive namespace + +## Skill Visibility + +| Visibility | Description | +|------------|-------------| +| `PUBLIC` | Visible to everyone, anonymous downloadable | +| `NAMESPACE_ONLY` | Only visible to namespace members | +| `PRIVATE` | Only visible to owner and namespace ADMIN | + +## Next Steps + +- [Promote to Global](./promotion) - Promote team skills to global diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/promotion.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/promotion.md new file mode 100644 index 00000000..610a18b9 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/collaboration/promotion.md @@ -0,0 +1,42 @@ +--- +title: Promote to Global +sidebar_position: 2 +description: Apply to promote team skills to global namespace +--- + +# Promote to Global + +Excellent team skills can be applied for promotion to the global namespace for enterprise-wide use. + +## Promotion Prerequisites + +- Skill is published in team namespace +- Applicant is skill owner or namespace ADMIN +- Skill has no pending promotion requests + +## Apply for Promotion + +1. Go to team skill detail page +2. Click "Promote to Global" +3. Fill in application description +4. Submit application + +## Review Workflow + +1. Platform admin receives promotion application +2. Reviews skill quality and suitability +3. After approval: + - Creates new skill in global namespace + - Preserves original team skill + - Records source traceability relationship + +## After Promotion + +- New skill in global namespace is independently managed +- Original team skill continues to exist +- Versions are not automatically synced +- Manual operation required if sync needed + +## Next Steps + +- [API Overview](../../developer/api/overview) - API integration diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/_category_.json new file mode 100644 index 00000000..53fc1202 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Discovery & Usage", + "position": 2 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/install.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/install.md new file mode 100644 index 00000000..a3f2bc74 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/install.md @@ -0,0 +1,53 @@ +--- +title: Install & Use +sidebar_position: 2 +description: Install and use skills +--- + +# Install & Use + +## Install via CLI + +### Install Latest Version + +```bash +clawhub install @team/my-skill +``` + +### Install Specific Version + +```bash +clawhub install @team/my-skill@1.2.0 +``` + +### Install by Tag + +```bash +clawhub install @team/my-skill@beta +``` + +### Install with ClawHub CLI + +```bash +clawhub install my-skill +clawhub install team-name--my-skill +``` + +## Installation Directory + +Install by the following priority: + +| Priority | Path | Description | +|----------|------|-------------| +| 1 | `./.agent/skills/` | Project level, universal mode | +| 2 | `~/.agent/skills/` | Global level, universal mode | +| 3 | `./.claude/skills/` | Project level, Claude default | +| 4 | `~/.claude/skills/` | Global level, Claude default | + +## Use in Claude Code + +After installation, skills are automatically discovered and loaded by Claude Code. + +## Next Steps + +- [Ratings & Stars](./ratings) - Feedback and favorite skills diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/ratings.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/ratings.md new file mode 100644 index 00000000..3dbf1abf --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/ratings.md @@ -0,0 +1,30 @@ +--- +title: Ratings & Stars +sidebar_position: 3 +description: Skill rating and favorite features +--- + +# Ratings & Stars + +## Favorite Skills + +Click the "Favorite" button on the skill detail page to favorite a skill. + +View your favorite skills: +- Web: Go to "My Favorites" +- CLI: `clawhub stars` + +## Skill Rating + +You can rate skills from 1-5 stars: + +1. Go to skill detail page +2. Click rating area +3. Select rating (1-5 stars) +4. Submit rating + +You can modify your rating at any time. + +## Next Steps + +- [Team Namespaces](../collaboration/namespaces) - Team collaboration diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md new file mode 100644 index 00000000..f0c1b3d4 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md @@ -0,0 +1,35 @@ +--- +title: Search Skills +sidebar_position: 1 +description: Search and filter skills +--- + +# Search Skills + +## Full-text Search + +Enter keywords in the search box, SkillHub searches in the following fields: +- Skill name +- Skill description +- SKILL.md body content +- Keywords + +## Filter Conditions + +You can filter search results by the following conditions: +- Namespace +- Visibility +- Download count sorting +- Rating sorting +- Update time sorting + +## Advanced Search + +Use search syntax: +- `namespace:@team-ai` - Specify namespace +- `category:code-review` - Specify category +- `downloads:>100` - Downloads greater than 100 + +## Next Steps + +- [Install & Use](./install) - Install and use skills diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/_category_.json new file mode 100644 index 00000000..0942921d --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Publishing Skills", + "position": 1 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/create-skill.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/create-skill.md new file mode 100644 index 00000000..1535dfd6 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/create-skill.md @@ -0,0 +1,56 @@ +--- +title: Create Skill Package +sidebar_position: 1 +description: Learn how to create a compliant skill package +--- + +# Create Skill Package + +## Skill Package Structure + +A standard SkillHub skill package structure looks like this: + +``` +my-skill/ +├── SKILL.md # Main entry file (required) +├── references/ # References (optional) +├── scripts/ # Scripts (optional) +└── assets/ # Static assets (optional) +``` + +## SKILL.md Format + +SKILL.md is the main entry file of a skill package, using YAML frontmatter + Markdown body format: + +```markdown +--- +name: my-skill +description: One sentence describing what this skill is for +x-astron-category: code-review +--- + +# Skill Description + +Detailed skill description goes here... +``` + +### Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Skill identifier, kebab-case format | +| `description` | Yes | Brief skill description | +| `x-astron-category` | No | Category tag | +| `x-astron-runtime` | No | Runtime requirement | +| `x-astron-min-version` | No | Minimum version requirement | + +## File Limits + +- Single file size: Max 1MB +- Total package size: Max 10MB +- File count: Max 100 +- Allowed file types: `.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` + +## Next Steps + +- [Publish Workflow](./publish) - Publish skill package diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/publish.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/publish.md new file mode 100644 index 00000000..5421394e --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/publish.md @@ -0,0 +1,50 @@ +--- +title: Publish Workflow +sidebar_position: 2 +description: Publish skills to SkillHub +--- + +# Publish Workflow + +## Publish via Web + +1. Login to SkillHub +2. Click "Publish Skill" +3. Select target namespace +4. Upload skill package ZIP file +5. Fill in version information (changelog, etc.) +6. Submit publishing +7. Wait for review (if required) +8. Published successfully after review approval + +## Publish via CLI + +```bash +# 1. Login +skillhub login + +# 2. Publish +skillhub publish ./my-skill.zip --namespace @team-myteam +``` + +## Publish via ClawHub CLI + +Use after configuring registry: + +```bash +clawhub publish ./my-skill.zip +``` + +## Publishing Status + +| Status | Description | +|--------|-------------| +| `DRAFT` | Draft, not submitted for review | +| `PENDING_REVIEW` | Pending review | +| `PUBLISHED` | Published, discoverable and downloadable | +| `REJECTED` | Rejected, need modification and resubmit | +| `YANKED` | Withdrawn, no longer recommended for use | + +## Next Steps + +- [Version Management](./versioning) - Manage skill versions diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/versioning.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/versioning.md new file mode 100644 index 00000000..f73e9393 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/publishing/versioning.md @@ -0,0 +1,56 @@ +--- +title: Version Management +sidebar_position: 3 +description: Skill version and tag management +--- + +# Version Management + +## Semantic Versioning + +SkillHub uses Semantic Versioning: `MAJOR.MINOR.PATCH` + +- `MAJOR`: Incompatible API changes +- `MINOR`: Backward compatible feature additions +- `PATCH`: Backward compatible bug fixes + +Examples: `1.0.0`, `1.1.0`, `2.0.0` + +## latest Tag + +`latest` is a system reserved tag that automatically follows the latest published version and cannot be manually moved. + +## Custom Tags + +You can create custom tags for version channel management: + +- `beta` - Beta version +- `stable` - Stable version +- `stable-2026q1` - Quarterly stable version + +### Create/Move Tag + +```bash +clawhub tag set @team/my-skill beta 1.2.0 +``` + +### Delete Tag + +```bash +clawhub tag delete @team/my-skill beta +``` + +## Version Withdrawal + +Published versions with issues can be withdrawn: + +1. Go to skill detail page +2. Find target version +3. Click "Withdraw Version" +4. Confirm withdrawal + +Withdrawn versions remain visible but are marked as not recommended for use. + +## Next Steps + +- [Search Skills](../discovery/search) - Discover skills diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/_category_.json new file mode 100644 index 00000000..12502727 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Developer Reference", + "position": 4, + "link": { + "type": "generated-index", + "description": "API reference, architecture design, and extension development" + } +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/_category_.json new file mode 100644 index 00000000..27b046c0 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "API Reference", + "position": 1 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/authenticated.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/authenticated.md new file mode 100644 index 00000000..3742cb2b --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/authenticated.md @@ -0,0 +1,101 @@ +--- +title: Authenticated APIs +sidebar_position: 3 +description: APIs requiring authentication +--- + +# Authenticated APIs + +## Authentication Related + +### Get Current User + +```http +GET /api/v1/auth/me +``` + +### Logout + +```http +POST /api/v1/auth/logout +``` + +## Skill Publishing + +```http +POST /api/v1/publish +Content-Type: multipart/form-data + +file: +namespace: +``` + +## Favorites + +```http +POST /api/v1/skills/{namespace}/{slug}/star +DELETE /api/v1/skills/{namespace}/{slug}/star +``` + +## Ratings + +```http +POST /api/v1/skills/{namespace}/{slug}/rating +Content-Type: application/json + +{ + "score": 5 +} +``` + +## Tag Management + +```http +GET /api/v1/skills/{namespace}/{slug}/tags +PUT /api/v1/skills/{namespace}/{slug}/tags/{tagName} +DELETE /api/v1/skills/{namespace}/{slug}/tags/{tagName} +``` + +## My Resources + +```http +GET /api/v1/me/stars +GET /api/v1/me/skills +``` + +## Namespace Management + +```http +POST /api/v1/namespaces +PUT /api/v1/namespaces/{slug} +GET /api/v1/namespaces/{slug}/members +POST /api/v1/namespaces/{slug}/members +PUT /api/v1/namespaces/{slug}/members/{userId}/role +DELETE /api/v1/namespaces/{slug}/members/{userId} +``` + +## Reviews + +```http +GET /api/v1/namespaces/{slug}/reviews +POST /api/v1/namespaces/{slug}/reviews/{id}/approve +POST /api/v1/namespaces/{slug}/reviews/{id}/reject +``` + +## Promotion Requests + +```http +POST /api/v1/namespaces/{slug}/skills/{skillId}/promote +``` + +## API Token + +```http +POST /api/v1/tokens +GET /api/v1/tokens +DELETE /api/v1/tokens/{id} +``` + +## Next Steps + +- [CLI Compatibility Layer](./cli-compat) - ClawHub compatible endpoints diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/cli-compat.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/cli-compat.md new file mode 100644 index 00000000..11f5e688 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/cli-compat.md @@ -0,0 +1,159 @@ +--- +title: CLI Compatibility Layer +sidebar_position: 4 +description: ClawHub CLI protocol compatibility layer +--- + +# CLI Compatibility Layer + +SkillHub provides a ClawHub CLI protocol compatibility layer for seamless migration of existing tools. + +## Configuring ClawHub CLI + +To connect ClawHub CLI to your SkillHub instance, configure the following environment variables: + +### Environment Variable Configuration + +**Linux/macOS (bash/zsh):** +```bash +# ~/.bashrc or ~/.zshrc +export CLAWHUB_SITE=https://skill.xfyun.cn +export CLAWHUB_REGISTRY=https://skill.xfyun.cn +``` + +**Windows (PowerShell):** +```powershell +# Permanent setting (current user) +[Environment]::SetEnvironmentVariable('CLAWHUB_SITE', 'https://skill.xfyun.cn', 'User') +[Environment]::SetEnvironmentVariable('CLAWHUB_REGISTRY', 'https://skill.xfyun.cn', 'User') + +# Or temporary setting (current session) +$env:CLAWHUB_SITE = 'https://skill.xfyun.cn' +$env:CLAWHUB_REGISTRY = 'https://skill.xfyun.cn' +``` + +### Using CLI Flags (Single Command) + +```bash +clawhub --site https://skill.xfyun.cn --registry https://skill.xfyun.cn install +``` + +### One-click Copy from Web UI + +The SkillHub skill detail page automatically displays install commands with the correct environment variables pre-configured. Simply copy and use. + +## Well-known Discovery + +```http +GET /.well-known/clawhub.json +``` + +Response: + +```json +{ + "apiBase": "/api/v1" +} +``` + +## Compatibility Layer APIs + +### Whoami + +```http +GET /api/v1/whoami +``` + +Response: + +```json +{ + "handle": "username", + "displayName": "User Name", + "role": "user" +} +``` + +### Search + +```http +GET /api/v1/search?q={keyword}&page={page}&limit={limit} +``` + +Response: + +```json +{ + "results": [ + { + "slug": "my-skill", + "name": "My Skill", + "description": "...", + "author": { + "handle": "username", + "displayName": "User Name" + }, + "version": "1.2.0", + "downloadCount": 100, + "starCount": 50, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-03-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### Resolve + +```http +GET /api/v1/resolve?slug={slug}&version={version} +``` + +Response: + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "downloadUrl": "/api/v1/download/my-skill/1.2.0" +} +``` + +### Download + +```http +GET /api/v1/download/{slug}/{version} +``` + +### Publish + +```http +POST /api/v1/publish +Content-Type: multipart/form-data + +file: +``` + +Response: + +```json +{ + "slug": "my-skill", + "version": "1.0.0", + "status": "published" +} +``` + +## Coordinate Mapping + +| SkillHub Coordinate | ClawHub canonical slug | +|---------------------|------------------------| +| `@global/my-skill` | `my-skill` | +| `@team-name/my-skill` | `team-name--my-skill` | + +## Next Steps + +- [System Architecture](../architecture/overview) - Understand architecture design diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/overview.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/overview.md new file mode 100644 index 00000000..b7e2a1d0 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/overview.md @@ -0,0 +1,86 @@ +--- +title: API Overview +sidebar_position: 1 +description: SkillHub API overview +--- + +# API Overview + +SkillHub provides RESTful APIs for integration and automation. + +## API Categories + +### Public APIs +- Skill search +- Skill details +- Version list +- Download skills +- No authentication required (for PUBLIC skills) + +### Authenticated APIs +- Publish skills +- Favorites/ratings +- Namespace management +- Requires login or Bearer Token + +### CLI Compatibility Layer +- ClawHub CLI protocol compatible +- Existing tools can migrate seamlessly + +## Response Format + +### Unified Response Structure + +```json +{ + "code": 0, + "msg": "Success", + "data": {}, + "timestamp": "2026-03-15T06:00:00Z", + "requestId": "req-123" +} +``` + +### Pagination Response + +```json +{ + "code": 0, + "msg": "Success", + "data": { + "items": [], + "total": 100, + "page": 1, + "size": 20 + }, + "timestamp": "2026-03-15T06:00:00Z", + "requestId": "req-123" +} +``` + +## Authentication Methods + +### Session Cookie +Web side uses Session Cookie authentication. + +### Bearer Token +CLI and API integration use Bearer Token: + +```bash +Authorization: Bearer +``` + +### API Token +Can create long-lived API Tokens for automation. + +## Idempotency + +All write operations support `X-Request-Id` header for idempotency: + +```bash +X-Request-Id: +``` + +## Next Steps + +- [Public APIs](./public) - View public endpoints diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/public.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/public.md new file mode 100644 index 00000000..7e8a01cc --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/api/public.md @@ -0,0 +1,72 @@ +--- +title: Public APIs +sidebar_position: 2 +description: Public APIs without authentication +--- + +# Public APIs + +## Skill Search + +```http +GET /api/v1/skills?keyword=...&namespace=...&page=1&size=20 +``` + +**Query Parameters:** +- `keyword`: Search keyword +- `namespace`: Namespace filter +- `page`: Page number +- `size`: Page size + +## Skill Details + +```http +GET /api/v1/skills/{namespace}/{slug} +``` + +## Version List + +```http +GET /api/v1/skills/{namespace}/{slug}/versions +``` + +## Version Details + +```http +GET /api/v1/skills/{namespace}/{slug}/versions/{version} +``` + +## File List + +```http +GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files +``` + +## Download Skill + +```http +GET /api/v1/skills/{namespace}/{slug}/download +GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download +``` + +## Resolve Version + +```http +GET /api/v1/skills/{namespace}/{slug}/resolve?version=...&tag=... +``` + +## Namespace List + +```http +GET /api/v1/namespaces +``` + +## Namespace Details + +```http +GET /api/v1/namespaces/{slug} +``` + +## Next Steps + +- [Authenticated APIs](./authenticated) - View authenticated endpoints diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/_category_.json new file mode 100644 index 00000000..b7a3ae9d --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Architecture", + "position": 2 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/domain-model.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/domain-model.md new file mode 100644 index 00000000..fb760e38 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/domain-model.md @@ -0,0 +1,77 @@ +--- +title: Domain Model +sidebar_position: 2 +description: Core domain entities and relationships +--- + +# Domain Model + +## Core Entities + +### Namespace + +| Field | Type | Description | +|-------|------|-------------| +| id | bigint | Primary key | +| slug | varchar(64) | URL-friendly identifier | +| display_name | varchar(128) | Display name | +| type | enum | `GLOBAL` / `TEAM` | +| description | text | Description | +| status | enum | `ACTIVE` / `FROZEN` / `ARCHIVED` | + +### NamespaceMember + +| Field | Type | Description | +|-------|------|-------------| +| id | bigint | Primary key | +| namespace_id | bigint | Namespace ID | +| user_id | varchar(128) | User ID | +| role | enum | `OWNER` / `ADMIN` / `MEMBER` | + +### Skill + +| Field | Type | Description | +|-------|------|-------------| +| id | bigint | Primary key | +| namespace_id | bigint | Parent namespace | +| slug | varchar(128) | URL-friendly identifier | +| display_name | varchar(256) | Display name | +| summary | varchar(512) | Summary | +| owner_id | varchar(128) | Primary maintainer | +| visibility | enum | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | +| status | enum | `ACTIVE` / `HIDDEN` / `ARCHIVED` | +| latest_version_id | bigint | Latest published version | + +**Unique constraint**: `(namespace_id, slug)` + +### SkillVersion + +| Field | Type | Description | +|-------|------|-------------| +| id | bigint | Primary key | +| skill_id | bigint | Skill ID | +| version | varchar(32) | semver version | +| status | enum | `DRAFT` / `PENDING_REVIEW` / `PUBLISHED` / `REJECTED` / `YANKED` | +| manifest_json | json | File manifest | +| parsed_metadata_json | json | SKILL.md parsed result | + +**Unique constraint**: `(skill_id, version)` + +### SkillTag + +| Field | Type | Description | +|-------|------|-------------| +| id | bigint | Primary key | +| skill_id | bigint | Skill ID | +| tag_name | varchar(64) | Tag name | +| target_version_id | bigint | Target version | + +**Unique constraint**: `(skill_id, tag_name)` + +## Coordinate System + +Full skill address: `@{namespace_slug}/{skill_slug}` + +## Next Steps + +- [Security Architecture](./security) - Security design diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/overview.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/overview.md new file mode 100644 index 00000000..37b87afe --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/overview.md @@ -0,0 +1,69 @@ +--- +title: System Architecture +sidebar_position: 1 +description: SkillHub system architecture overview +--- + +# System Architecture + +## Architecture Principles + +- **Monolith-first**: Phase 1 uses modular monolith, no microservices +- **Dependency Inversion**: Domain layer does not depend on infrastructure +- **Replaceable Boundaries**: Search and storage both have SPI abstractions + +## Module Structure + +``` +server/ +├── skillhub-app/ # Startup, configuration assembly, Controllers +├── skillhub-domain/ # Domain models + domain services + application services +├── skillhub-auth/ # OAuth2 authentication + RBAC + authorization decisions +├── skillhub-search/ # Search SPI + PostgreSQL full-text implementation +├── skillhub-storage/ # Object storage abstraction + LocalFile/S3 +└── skillhub-infra/ # JPA, utilities, configuration foundation +``` + +## Module Dependencies + +``` +app → domain, auth, search, storage, infra +infra → domain +auth → domain +search → domain +storage → (independent) +``` + +## Tech Stack + +| Layer | Technology | Version | +|-------|------------|---------| +| Runtime | Java | 21 | +| Framework | Spring Boot | 3.2.3 | +| Database | PostgreSQL | 16.x | +| Cache/Session | Redis | 7.x | + +## Deployment Architecture + +``` +┌──────────────┐ +│ Browser / CLI│ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Web/Nginx │ +└──────┬───────┘ + │ /api/* + ▼ +┌──────────────┐ +│ Spring Boot │ +└───┬────┬─────┘ + │ │ + ▼ ▼ +PostgreSQL Redis +``` + +## Next Steps + +- [Domain Model](./domain-model) - Core entities diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/security.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/security.md new file mode 100644 index 00000000..d967dc40 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/architecture/security.md @@ -0,0 +1,71 @@ +--- +title: Security Architecture +sidebar_position: 3 +description: Security architecture design +--- + +# Security Architecture + +## Authentication Architecture + +### OAuth2 Login + +- Based on Spring Security OAuth2 Client +- Phase 1 supports GitHub +- Architecture supports extending multiple providers + +### CLI Authentication + +- OAuth Device Flow +- Web authorization issues CLI credentials +- Supports API Token + +### Session Management + +- Spring Session + Redis +- Distributed session sharing +- Supports multi-pod deployment + +## Authorization Architecture + +### Platform Roles + +| Role | Permissions | +|------|-------------| +| `SUPER_ADMIN` | All permissions | +| `SKILL_ADMIN` | Skill governance | +| `USER_ADMIN` | User governance | +| `AUDITOR` | Audit read-only | + +### Namespace Roles + +| Role | Permissions | +|------|-------------| +| `OWNER` | Namespace owner | +| `ADMIN` | Review, member management | +| `MEMBER` | Publish skills | + +### Visibility Rules + +| Visibility | Who can access | +|------------|----------------| +| `PUBLIC` | Anyone (anonymous) | +| `NAMESPACE_ONLY` | Namespace members | +| `PRIVATE` | owner + namespace ADMIN | + +## Auditing + +All critical operations synchronously write to audit logs: +- Publish, download, delete +- Review approval, rejection +- Permission changes +- Configuration changes + +## Rate Limiting + +- Ingress layer basic rate limiting (Nginx) +- Application layer fine-grained rate limiting (Redis sliding window) + +## Next Steps + +- [Skill Protocol](../plugins/skill-protocol) - Skill package specification diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/_category_.json new file mode 100644 index 00000000..f6f56166 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Extensions & Integrations", + "position": 3 +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/skill-protocol.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/skill-protocol.md new file mode 100644 index 00000000..7033af7a --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/skill-protocol.md @@ -0,0 +1,68 @@ +--- +title: Skill Protocol +sidebar_position: 1 +description: SKILL.md specification and skill package protocol +--- + +# Skill Protocol + +## SKILL.md Specification + +### Basic Format + +```markdown +--- +name: my-skill +description: When to use this skill +--- + +# Markdown Body + +Skill instruction content... +``` + +### Required Fields + +| Field | Description | +|-------|-------------| +| `name` | Skill identifier, kebab-case | +| `description` | Brief skill description | + +### Extension Fields + +| Field | Description | +|-------|-------------| +| `x-astron-category` | Category tag | +| `x-astron-runtime` | Runtime requirement | +| `x-astron-min-version` | Minimum version requirement | + +## Skill Package Structure + +``` +my-skill/ +├── SKILL.md # Main entry file (required) +├── references/ # References (optional) +├── scripts/ # Scripts (optional) +└── assets/ # Static assets (optional) +``` + +## File Validation + +- Root directory must contain `SKILL.md` +- File type whitelist +- Single file size limit: 1MB +- Total package size limit: 10MB +- File count limit: 100 + +## Client Installation Directory + +Install by the following priority: + +1. `./.agent/skills/` +2. `~/.agent/skills/` +3. `./.claude/skills/` +4. `~/.claude/skills/` + +## Next Steps + +- [Storage SPI](./storage-spi) - Extend storage backend diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/storage-spi.md b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/storage-spi.md new file mode 100644 index 00000000..f3a0a57d --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/04-developer/plugins/storage-spi.md @@ -0,0 +1,54 @@ +--- +title: Storage SPI +sidebar_position: 2 +description: Storage service provider extension +--- + +# Storage SPI + +## SPI Interface + +```java +public interface ObjectStorageService { + void store(String key, InputStream content, String contentType); + InputStream retrieve(String key); + void delete(String key); + boolean exists(String key); +} +``` + +## Built-in Implementations + +### LocalFileStorageService + +Local filesystem implementation for development environment. + +### S3StorageService + +S3 protocol compatible implementation, supports: +- AWS S3 +- MinIO +- Alibaba Cloud OSS +- Tencent Cloud COS +- Other S3-compatible storage + +## Configuration + +```bash +# Select storage provider +SKILLHUB_STORAGE_PROVIDER=s3 + +# S3 configuration +SKILLHUB_STORAGE_S3_ENDPOINT=https://s3.example.com +SKILLHUB_STORAGE_S3_BUCKET=skillhub +SKILLHUB_STORAGE_S3_ACCESS_KEY=xxx +SKILLHUB_STORAGE_S3_SECRET_KEY=xxx +``` + +## Custom Implementation + +Implement `ObjectStorageService` interface and register as Spring Bean. + +## Next Steps + +- [FAQ](../../reference/faq) - FAQ diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/_category_.json b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/_category_.json new file mode 100644 index 00000000..3bad6b7f --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Reference", + "position": 5, + "link": { + "type": "generated-index", + "description": "FAQ, troubleshooting, and more resources" + } +} diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/changelog.md b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/changelog.md new file mode 100644 index 00000000..3517a921 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/changelog.md @@ -0,0 +1,20 @@ +--- +title: Changelog +sidebar_position: 3 +description: Version change history +--- + +# Changelog + +## [Unreleased] + +### Added +- Initial version release +- Skill publishing and management +- Namespace and RBAC +- Full-text search +- ClawHub CLI compatibility layer + +## Next Steps + +- [Roadmap](./roadmap) - Future plans diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/faq.md b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/faq.md new file mode 100644 index 00000000..137a90e7 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/faq.md @@ -0,0 +1,49 @@ +--- +title: FAQ +sidebar_position: 1 +description: Frequently asked questions +--- + +# FAQ + +## Deployment Related + +### How to change default port? + +Modify port configuration in `.env.release`. + +### How to configure HTTPS? + +Recommended to use reverse proxy (Nginx/Ingress) for TLS termination. + +### How to backup database? + +Use PostgreSQL standard backup tools (pg_dump). + +## Usage Related + +### How to reset admin password? + +If you forgot admin password, you can reconfigure bootstrap admin via environment variables or directly operate the database. + +### Skill package upload failed? + +Check: +1. Whether file size exceeds limit +2. Whether file type is in whitelist +3. Whether required SKILL.md is included +4. Whether SKILL.md frontmatter format is correct + +## Development Related + +### How to extend OAuth Provider? + +Refer to existing GitHub implementation, add new OAuth Provider configuration. + +### How to customize search implementation? + +Implement `SearchIndexService` and `SearchQueryService` interfaces. + +## Next Steps + +- [Troubleshooting](./troubleshooting) - Problem diagnosis diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/roadmap.md b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/roadmap.md new file mode 100644 index 00000000..fb62facf --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/roadmap.md @@ -0,0 +1,45 @@ +--- +title: Roadmap +sidebar_position: 4 +description: Future development roadmap +--- + +# Roadmap + +## Phase 1: Foundation ✅ + +- GitHub OAuth login +- Session management +- RBAC permission system + +## Phase 2: Skill Core ✅ + +- Namespace management +- Skill publishing and download +- Version management +- PostgreSQL full-text search + +## Phase 3: Review and CLI + +- Review workflow +- Skill promotion to global +- CLI tool +- Favorites and ratings + +## Phase 4: Operations and Polish + +- Audit logs +- Admin dashboard +- Observability +- Deployment optimization + +## Phase 5: Advanced Features + +- Comments and reports +- Automatic security scanning +- Vector search +- Webhook notifications + +## Next Steps + +- [Quick Start](../getting-started/quick-start) - Get started diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/troubleshooting.md b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/troubleshooting.md new file mode 100644 index 00000000..95716b51 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/05-reference/troubleshooting.md @@ -0,0 +1,63 @@ +--- +title: Troubleshooting +sidebar_position: 2 +description: Common problem diagnosis and solutions +--- + +# Troubleshooting + +## Service Cannot Start + +### Checklist + +1. Check container status: `docker compose ps` +2. View service logs: `docker compose logs ` +3. Verify environment variables: Check `.env.release` configuration +4. Check port occupancy: `netstat -tlnp` + +### Common Causes + +- Port occupied +- Database connection failed +- Redis connection failed +- Environment variables missing + +## Upload Failed + +### Skill Package Upload Failed + +1. Check file size +2. Check file type +3. Check SKILL.md format +4. View server logs + +## Authentication Issues + +### Cannot Login + +1. Check OAuth configuration +2. Check callback URL configuration +3. Check `SKILLHUB_PUBLIC_BASE_URL` configuration + +## Performance Issues + +### Slow Search + +1. Check PostgreSQL full-text index +2. Consider upgrading to Elasticsearch (future version) + +### Slow Download + +1. Check object storage configuration +2. Check network bandwidth + +## Get Help + +If above solutions cannot resolve the issue: +1. View logs +2. Submit Issue +3. Contact technical support + +## Next Steps + +- [Changelog](./changelog) - Version history diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/index.md b/document/i18n/en/docusaurus-plugin-content-docs/current/index.md new file mode 100644 index 00000000..21632c45 --- /dev/null +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/index.md @@ -0,0 +1,101 @@ +--- +title: SkillHub Documentation +sidebar_position: 1 +description: Enterprise-grade AI Skill Registry - Secure, controllable skill publishing, discovery, and management platform +--- + +# SkillHub + +
+
+

🏢 Enterprise-grade AI Skill Registry

+

+ Secure, controllable skill publishing, discovery, and management platform with full enterprise data sovereignty +

+ +
+
+ +--- + +## Enterprise Value + +
+
+
+
🔐
+

Data Sovereignty

+

+ Self-hosted deployment, data stays within your network; private S3/MinIO storage; complete audit trail +

+
+
+
+
+
🏢
+

Complete Governance

+

+ Namespace isolation; two-tier review workflow; fine-grained RBAC access control +

+
+
+
+
+
🔌
+

Strong Integration

+

+ ClawHub CLI compatible; standard REST API; OAuth2 enterprise SSO integration +

+
+
+
+
+
📊
+

Full Observability

+

+ Complete audit logs; Prometheus metrics; operation tracking and traceability +

+
+
+
+ +--- + +## Core Features + +
+
+ Version Control + Full-text Search + Namespaces + Review Workflow + Semantic Versioning + Multi-dimensional Filtering + RBAC Permissions + Audit Logs +
+
+ +--- + +## Quick Start + +
+
+ $ curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +
+

+ Visit http://localhost:3000 to get started +

+
+ +--- + +## Next Steps + +- [Quick Start](./getting-started/quick-start) - Deploy SkillHub with one command +- [Overview](./getting-started/overview) - Learn more about product features +- [Deployment Guide](./administration/deployment/single-machine) - Production deployment diff --git a/document/package.json b/document/package.json new file mode 100644 index 00000000..b67b60c9 --- /dev/null +++ b/document/package.json @@ -0,0 +1,47 @@ +{ + "name": "skillhub-docs", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "start": "docusaurus start", + "build": "docusaurus build", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids", + "typecheck": "tsc" + }, + "dependencies": { + "@docusaurus/core": "^3.9.2", + "@docusaurus/preset-classic": "^3.9.2", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "prism-react-renderer": "^2.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "^3.9.2", + "@docusaurus/tsconfig": "^3.9.2", + "@docusaurus/types": "^3.9.2", + "typescript": "~5.2.2" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + }, + "engines": { + "node": ">=18.0" + } +} diff --git a/document/sidebars.js b/document/sidebars.js new file mode 100644 index 00000000..d45d11d8 --- /dev/null +++ b/document/sidebars.js @@ -0,0 +1,140 @@ +/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +const sidebars = { + docsSidebar: [ + 'index', + { + type: 'category', + label: '快速入门', + link: { + type: 'generated-index', + }, + items: [ + 'getting-started/overview', + 'getting-started/quick-start', + 'getting-started/use-cases', + ], + }, + { + type: 'category', + label: '管理员指南', + link: { + type: 'generated-index', + }, + items: [ + { + type: 'category', + label: '部署指南', + items: [ + 'administration/deployment/single-machine', + 'administration/deployment/kubernetes', + 'administration/deployment/configuration', + ], + }, + { + type: 'category', + label: '安全与合规', + items: [ + 'administration/security/authentication', + 'administration/security/authorization', + 'administration/security/audit-logs', + ], + }, + { + type: 'category', + label: '治理与运营', + items: [ + 'administration/governance/namespaces', + 'administration/governance/review-workflow', + 'administration/governance/user-management', + ], + }, + ], + }, + { + type: 'category', + label: '用户指南', + link: { + type: 'generated-index', + }, + items: [ + { + type: 'category', + label: '发布技能', + items: [ + 'user-guide/publishing/create-skill', + 'user-guide/publishing/publish', + 'user-guide/publishing/versioning', + ], + }, + { + type: 'category', + label: '发现与使用', + items: [ + 'user-guide/discovery/search', + 'user-guide/discovery/install', + 'user-guide/discovery/ratings', + ], + }, + { + type: 'category', + label: '协作', + items: [ + 'user-guide/collaboration/namespaces', + 'user-guide/collaboration/promotion', + ], + }, + ], + }, + { + type: 'category', + label: '开发者参考', + link: { + type: 'generated-index', + }, + items: [ + { + type: 'category', + label: 'API 参考', + items: [ + 'developer/api/overview', + 'developer/api/public', + 'developer/api/authenticated', + 'developer/api/cli-compat', + ], + }, + { + type: 'category', + label: '架构设计', + items: [ + 'developer/architecture/overview', + 'developer/architecture/domain-model', + 'developer/architecture/security', + ], + }, + { + type: 'category', + label: '扩展与集成', + items: [ + 'developer/plugins/skill-protocol', + 'developer/plugins/storage-spi', + ], + }, + ], + }, + { + type: 'category', + label: '参考资料', + link: { + type: 'generated-index', + }, + items: [ + 'reference/faq', + 'reference/troubleshooting', + 'reference/changelog', + 'reference/roadmap', + ], + }, + ], +}; + +export default sidebars; diff --git a/document/src/css/custom.css b/document/src/css/custom.css new file mode 100644 index 00000000..99a39951 --- /dev/null +++ b/document/src/css/custom.css @@ -0,0 +1,215 @@ +/** + * SkillHub 自定义样式 - 讯飞蓝色系 + */ + +:root { + /* 主色:讯飞蓝 */ + --ifm-color-primary: #0052D9; + --ifm-color-primary-dark: #003DA6; + --ifm-color-primary-darker: #002E7A; + --ifm-color-primary-darkest: #001F52; + --ifm-color-primary-light: #4080FF; + --ifm-color-primary-lighter: #6699FF; + --ifm-color-primary-lightest: #99BBFF; + + /* 辅助色 */ + --ifm-color-secondary: #00B42A; + --ifm-color-warning: #FF7D00; + --ifm-color-danger: #F53F3F; + + /* 中性色 */ + --ifm-color-gray-0: #F7F8FA; + --ifm-color-gray-100: #F2F3F5; + --ifm-color-gray-200: #E5E6EB; + --ifm-color-gray-300: #C9CDD4; + --ifm-color-gray-400: #86909C; + --ifm-color-gray-500: #4E5969; + --ifm-color-gray-600: #272E3B; + --ifm-color-gray-700: #1D2129; + --ifm-color-gray-800: #0F1218; + --ifm-color-gray-900: #000000; + + /* 背景色 */ + --ifm-background-color: #FFFFFF; + --ifm-background-surface-color: #F7F8FA; + + /* 文本色 */ + --ifm-font-color-base: #1D2129; + --ifm-font-color-secondary: #4E5969; + --ifm-font-color-muted: #86909C; + + /* 导航栏 */ + --ifm-navbar-background-color: #FFFFFF; + --ifm-navbar-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + + /* 侧边栏 */ + --ifm-sidebar-background-color: #F7F8FA; + --ifm-sidebar-border-color: #E5E6EB; +} + +/* 深色模式 */ +[data-theme='dark'] { + --ifm-color-primary: #4080FF; + --ifm-color-primary-dark: #0052D9; + --ifm-color-primary-darker: #003DA6; + --ifm-color-primary-darkest: #002E7A; + --ifm-color-primary-light: #6699FF; + --ifm-color-primary-lighter: #99BBFF; + --ifm-color-primary-lightest: #CCDDFF; + + --ifm-background-color: #1D2129; + --ifm-background-surface-color: #272E3B; + + --ifm-font-color-base: #F7F8FA; + --ifm-font-color-secondary: #C9CDD4; + --ifm-font-color-muted: #86909C; + + --ifm-navbar-background-color: #272E3B; + --ifm-sidebar-background-color: #272E3B; + --ifm-sidebar-border-color: #3E475A; +} + +/* 企业价值卡片样式 */ +.enterprise-value-card { + border: 1px solid var(--ifm-color-gray-200); + border-radius: 12px; + padding: 24px; + transition: all 0.2s ease; + background: var(--ifm-background-color); +} + +.enterprise-value-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 82, 217, 0.12); + border-color: var(--ifm-color-primary-light); +} + +.enterprise-value-card__icon { + font-size: 32px; + margin-bottom: 12px; +} + +.enterprise-value-card__title { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; + color: var(--ifm-font-color-base); +} + +.enterprise-value-card__description { + font-size: 14px; + color: var(--ifm-font-color-secondary); + line-height: 1.6; +} + +/* Hero 区域样式 */ +.hero-section { + padding: 80px 0; + text-align: center; + background: linear-gradient(135deg, #F7F8FA 0%, #FFFFFF 100%); +} + +[data-theme='dark'] .hero-section { + background: linear-gradient(135deg, #1D2129 0%, #272E3B 100%); +} + +.hero-section__title { + font-size: 48px; + font-weight: 700; + margin-bottom: 16px; + color: var(--ifm-font-color-base); +} + +.hero-section__tagline { + font-size: 20px; + color: var(--ifm-font-color-secondary); + margin-bottom: 32px; + max-width: 700px; + margin-left: auto; + margin-right: auto; +} + +.hero-section__cta { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.btn-primary { + background: var(--ifm-color-primary); + color: white; + padding: 12px 32px; + border-radius: 8px; + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; + display: inline-block; +} + +.btn-primary:hover { + background: var(--ifm-color-primary-dark); + color: white; + text-decoration: none; +} + +.btn-secondary { + background: transparent; + color: var(--ifm-color-primary); + padding: 12px 32px; + border-radius: 8px; + font-weight: 500; + text-decoration: none; + border: 1px solid var(--ifm-color-primary); + transition: all 0.2s ease; + display: inline-block; +} + +.btn-secondary:hover { + background: var(--ifm-color-primary); + color: white; + text-decoration: none; +} + +/* 功能特性标签样式 */ +.feature-tags { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; + margin-top: 24px; +} + +.feature-tag { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-gray-200); + padding: 8px 16px; + border-radius: 20px; + font-size: 14px; + color: var(--ifm-font-color-secondary); +} + +/* 快速开始代码块样式 */ +.quick-start-code { + background: var(--ifm-color-gray-800); + color: var(--ifm-color-gray-0); + padding: 16px 24px; + border-radius: 8px; + font-family: monospace; + font-size: 14px; + overflow-x: auto; + text-align: left; + max-width: 800px; + margin: 24px auto; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .hero-section__title { + font-size: 32px; + } + + .hero-section__tagline { + font-size: 16px; + } +} diff --git a/document/static/img/logo.svg b/document/static/img/logo.svg new file mode 100644 index 00000000..4ff7e911 --- /dev/null +++ b/document/static/img/logo.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/document/tsconfig.json b/document/tsconfig.json new file mode 100644 index 00000000..d250afae --- /dev/null +++ b/document/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/monitoring/docker-compose.monitoring.yml b/monitoring/docker-compose.monitoring.yml new file mode 100644 index 00000000..7ae63321 --- /dev/null +++ b/monitoring/docker-compose.monitoring.yml @@ -0,0 +1,17 @@ +services: + prometheus: + image: ${PROMETHEUS_IMAGE:-prom/prometheus:latest} + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + + grafana: + image: ${GRAFANA_IMAGE:-grafana/grafana:latest} + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + depends_on: + - prometheus diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 00000000..d2bf08fb --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: skillhub-backend + metrics_path: /actuator/prometheus + static_configs: + - targets: + - host.docker.internal:8080 diff --git a/scripts/check-openapi-generated.sh b/scripts/check-openapi-generated.sh new file mode 100755 index 00000000..ddfaad32 --- /dev/null +++ b/scripts/check-openapi-generated.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_DIR="$ROOT_DIR/server" +WEB_DIR="$ROOT_DIR/web" +API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log" +BUILD_LOG="${TMPDIR:-/tmp}/skillhub-openapi-build.log" +SERVER_PID="" +OPENAPI_URL="http://127.0.0.1:8080/v3/api-docs" + +print_log_tail() { + local log_file="$1" + + if [[ -f "$log_file" ]]; then + echo "--- Last 50 lines of $log_file ---" >&2 + tail -n 50 "$log_file" >&2 || true + fi +} + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fi + (cd "$ROOT_DIR" && docker compose down >/dev/null 2>&1) || true +} + +trap cleanup EXIT + +cd "$ROOT_DIR" +docker compose up -d --wait postgres redis + +( + cd "$SERVER_DIR" + SPRING_PROFILES_ACTIVE=local ./scripts/run-dev-app.sh +) >"$API_LOG" 2>&1 & +SERVER_PID=$! + +for _ in $(seq 1 90); do + if curl -fsS "$OPENAPI_URL" >/dev/null 2>&1; then + break + fi + + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Backend exited before exposing /v3/api-docs. See $API_LOG" >&2 + print_log_tail "$API_LOG" + exit 1 + fi + + sleep 2 +done + +if ! curl -fsS "$OPENAPI_URL" >/dev/null 2>&1; then + echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2 + print_log_tail "$API_LOG" + exit 1 +fi + +cd "$WEB_DIR" +pnpm run generate-api + +cd "$ROOT_DIR" +git diff --exit-code -- web/src/api/generated/schema.d.ts diff --git a/scripts/dev-process.sh b/scripts/dev-process.sh new file mode 100755 index 00000000..b5d2ebeb --- /dev/null +++ b/scripts/dev-process.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage:" >&2 + echo " $0 status --pid-file " >&2 + echo " $0 stop --pid-file " >&2 + echo " $0 start --pid-file --log-file --cwd -- " >&2 + exit 2 +} + +require_value() { + local flag="$1" + local value="${2:-}" + if [[ -z "$value" ]]; then + echo "Missing value for $flag" >&2 + usage + fi +} + +resolve_path() { + local path="$1" + if [[ "$path" = /* ]]; then + printf '%s\n' "$path" + else + printf '%s/%s\n' "$(pwd)" "$path" + fi +} + +is_running() { + local pid_file="$1" + [[ -f "$pid_file" ]] || return 1 + + local pid + pid="$(cat "$pid_file" 2>/dev/null || true)" + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + + if kill -0 "$pid" 2>/dev/null; then + return 0 + fi + + rm -f "$pid_file" + return 1 +} + +wait_for_exit() { + local pid="$1" + for _ in $(seq 1 50); do + if ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + sleep 0.1 + done + return 1 +} + +cmd="${1:-}" +[[ -n "$cmd" ]] || usage +shift || true + +pid_file="" +log_file="" +cwd="" + +case "$cmd" in + status|stop) + while [[ $# -gt 0 ]]; do + case "$1" in + --pid-file) + require_value "$1" "${2:-}" + pid_file="$2" + shift 2 + ;; + *) + usage + ;; + esac + done + [[ -n "$pid_file" ]] || usage + ;; + start) + while [[ $# -gt 0 ]]; do + case "$1" in + --pid-file) + require_value "$1" "${2:-}" + pid_file="$2" + shift 2 + ;; + --log-file) + require_value "$1" "${2:-}" + log_file="$2" + shift 2 + ;; + --cwd) + require_value "$1" "${2:-}" + cwd="$2" + shift 2 + ;; + --) + shift + break + ;; + *) + usage + ;; + esac + done + [[ -n "$pid_file" && -n "$log_file" && -n "$cwd" && $# -gt 0 ]] || usage + ;; + *) + usage + ;; +esac + +case "$cmd" in + status) + is_running "$pid_file" + ;; + stop) + if ! is_running "$pid_file"; then + rm -f "$pid_file" + exit 0 + fi + + pid="$(cat "$pid_file")" + kill "$pid" 2>/dev/null || true + if ! wait_for_exit "$pid"; then + kill -9 "$pid" 2>/dev/null || true + wait_for_exit "$pid" || true + fi + rm -f "$pid_file" + ;; + start) + pid_file="$(resolve_path "$pid_file")" + log_file="$(resolve_path "$log_file")" + cwd="$(resolve_path "$cwd")" + mkdir -p "$(dirname "$pid_file")" "$(dirname "$log_file")" + if is_running "$pid_file"; then + echo "Process already running with PID $(cat "$pid_file")" >&2 + exit 1 + fi + + ( + cd "$cwd" + if command -v setsid >/dev/null 2>&1; then + setsid "$@" >>"$log_file" 2>&1 < /dev/null & + else + nohup "$@" >>"$log_file" 2>&1 < /dev/null & + fi + child_pid=$! + disown "$child_pid" 2>/dev/null || true + echo "$child_pid" >"$pid_file" + ) + ;; +esac diff --git a/scripts/governance-smoke-test.sh b/scripts/governance-smoke-test.sh new file mode 100755 index 00000000..2e8c0fb3 --- /dev/null +++ b/scripts/governance-smoke-test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE_FILE="$(mktemp)" + +cleanup() { + rm -f "$COOKIE_FILE" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split('.'): + if not part: + continue + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +assert_json_expr() { + local description="$1" + local json="$2" + local script="$3" + if JSON_INPUT="$json" python3 - </dev/null + +ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$COOKIE_FILE" -c "$COOKIE_FILE") + +SUMMARY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/summary")" +assert_code "Governance summary endpoint is available" "$SUMMARY_RESPONSE" "0" +assert_json_expr "Governance summary exposes review/promotion/report counts" "$SUMMARY_RESPONSE" $'summary = data["data"]\nassert "pendingReviews" in summary\nassert "pendingPromotions" in summary\nassert "pendingReports" in summary' + +INBOX_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/inbox")" +assert_code "Governance inbox endpoint is available" "$INBOX_RESPONSE" "0" +assert_json_expr "Governance inbox returns paged items" "$INBOX_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0' + +ACTIVITY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/activity")" +assert_code "Governance activity endpoint is available" "$ACTIVITY_RESPONSE" "0" +assert_json_expr "Governance activity returns paged items" "$ACTIVITY_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0' + +NOTIFICATIONS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/notifications")" +assert_code "Governance notifications endpoint is available" "$NOTIFICATIONS_RESPONSE" "0" +assert_json_expr "Governance notifications returns a list" "$NOTIFICATIONS_RESPONSE" $'assert isinstance(data["data"], list)' + +REPORTS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/skill-reports?status=PENDING&page=0&size=5")" +assert_code "Admin report list endpoint is available" "$REPORTS_RESPONSE" "0" +assert_json_expr "Admin report list returns page metadata" "$REPORTS_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5' + +AUDIT_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/audit-logs?action=REVIEW_APPROVE&page=0&size=5")" +assert_code "Audit log endpoint is available for governance filters" "$AUDIT_RESPONSE" "0" +assert_json_expr "Audit log endpoint returns page metadata" "$AUDIT_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5' + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/mirror-runtime-images.sh b/scripts/mirror-runtime-images.sh new file mode 100644 index 00000000..fcd80ef9 --- /dev/null +++ b/scripts/mirror-runtime-images.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGES_FILE="${1:-deploy/runtime-mirror-images.txt}" + +: "${MIRROR_REGISTRY:?MIRROR_REGISTRY is required}" +: "${MIRROR_NAMESPACE:?MIRROR_NAMESPACE is required}" + +target_prefix="${MIRROR_REGISTRY%/}/${MIRROR_NAMESPACE}" + +while read -r source target; do + if [[ -z "${source}" || "${source}" == \#* ]]; then + continue + fi + + if [[ -z "${target:-}" ]]; then + echo "Missing target image mapping for ${source}" >&2 + exit 1 + fi + + if docker buildx imagetools inspect "${target_prefix}/${target}" >/dev/null 2>&1; then + echo "Skipping existing image ${target_prefix}/${target}" + continue + fi + + echo "Mirroring ${source} -> ${target_prefix}/${target}" + docker buildx imagetools create --tag "${target_prefix}/${target}" "${source}" +done < "${IMAGES_FILE}" diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh new file mode 100755 index 00000000..ae2b7b59 --- /dev/null +++ b/scripts/namespace-smoke-test.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +USER_COOKIE="$(mktemp)" +ADMIN_COOKIE="$(mktemp)" +SLUG="nsmoke$(date +%s)" + +cleanup() { + rm -f "$USER_COOKIE" "$ADMIN_COOKIE" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +csrf_token() { + local cookie_file="$1" + awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1 +} + +bootstrap_csrf() { + local cookie_file="$1" + local user_id="$2" + curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +value = data +for part in expr.split('.'): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +USER_HEADERS=(-H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE") +ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE") + +echo "=== Namespace Workflow Smoke Test ===" +echo "Target: $BASE_URL" +echo "Slug: $SLUG" +echo + +bootstrap_csrf "$USER_COOKIE" "local-user" +bootstrap_csrf "$ADMIN_COOKIE" "local-admin" + +USER_CSRF="$(csrf_token "$USER_COOKIE")" +ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")" + +if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then + echo "Could not bootstrap CSRF tokens" + exit 1 +fi + +CREATE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces" \ + -d "{\"slug\":\"$SLUG\",\"displayName\":\"Namespace Smoke $SLUG\",\"description\":\"namespace workflow smoke test\"}")" +assert_code "Owner can create namespace" "$CREATE_RESPONSE" "0" +NAMESPACE_ID="$(json_field "$CREATE_RESPONSE" "data.id")" + +MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Owner can list my namespaces" "$MINE_RESPONSE" "0" +if JSON_INPUT="$MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +match = next((item for item in items if item["slug"] == slug), None) +if not match: + raise SystemExit(1) +if match["currentUserRole"] != "OWNER": + raise SystemExit(2) +if match["status"] != "ACTIVE": + raise SystemExit(3) +PY +then + pass "Created namespace shows up as ACTIVE owner namespace" +else + fail "Created namespace should appear in owner namespace list with OWNER role" +fi + +ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Other user can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +PY +then + pass "Namespace is not visible to unrelated users in my namespaces" +else + fail "Unrelated user should not see team namespace in my namespaces" +fi + +FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Unrelated user cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" + +CANDIDATES_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" +assert_code "Owner can search namespace member candidates" "$CANDIDATES_RESPONSE" "0" +if JSON_INPUT="$CANDIDATES_RESPONSE" python3 - <<'PY' +import json +import os +import sys +data = json.loads(os.environ["JSON_INPUT"]) +ids = {item["userId"] for item in data["data"]} +raise SystemExit(0 if "local-admin" in ids else 1) +PY +then + pass "Candidate search returns local-admin" +else + fail "Candidate search should include local-admin" +fi + +ADD_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-admin","role":"MEMBER"}')" +assert_code "Owner can add namespace members" "$ADD_MEMBER_RESPONSE" "0" + +MEMBERS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" +assert_code "Owner can list namespace members" "$MEMBERS_RESPONSE" "0" +if JSON_INPUT="$MEMBERS_RESPONSE" python3 - <<'PY' +import json +import os +import sys +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"]["items"] +ids = {item["userId"] for item in items} +raise SystemExit(0 if {"local-user", "local-admin"}.issubset(ids) else 1) +PY +then + pass "Member list shows owner and invited admin user" +else + fail "Member list should contain owner and invited user" +fi + +REVIEWS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +assert_code "Owner can open namespace review list" "$REVIEWS_RESPONSE" "0" + +PROMOTE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin/role" \ + -d '{"role":"ADMIN"}')" +assert_code "Owner can promote member to admin" "$PROMOTE_RESPONSE" "0" + +ADMIN_FREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Namespace admin can freeze namespace" "$ADMIN_FREEZE_RESPONSE" "0" +if [[ "$(json_field "$ADMIN_FREEZE_RESPONSE" "data.status")" == "FROZEN" ]]; then + pass "Freeze changes namespace status to FROZEN" +else + fail "Freeze should set namespace status to FROZEN" +fi + +ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-user","role":"MEMBER"}')" +assert_code "Frozen namespace rejects member mutation" "$ADD_WHILE_FROZEN_RESPONSE" "400" + +ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/unfreeze")" +assert_code "Namespace admin can unfreeze namespace" "$ADMIN_UNFREEZE_RESPONSE" "0" + +ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ + -d '{"reason":"smoke"}')" +assert_code "Namespace admin cannot archive namespace" "$ADMIN_ARCHIVE_RESPONSE" "403" + +OWNER_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ + -d '{"reason":"smoke"}')" +assert_code "Owner can archive namespace" "$OWNER_ARCHIVE_RESPONSE" "0" +if [[ "$(json_field "$OWNER_ARCHIVE_RESPONSE" "data.status")" == "ARCHIVED" ]]; then + pass "Archive changes namespace status to ARCHIVED" +else + fail "Archive should set namespace status to ARCHIVED" +fi + +OWNER_RESTORE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/restore")" +assert_code "Owner can restore archived namespace" "$OWNER_RESTORE_RESPONSE" "0" +if [[ "$(json_field "$OWNER_RESTORE_RESPONSE" "data.status")" == "ACTIVE" ]]; then + pass "Restore changes namespace status back to ACTIVE" +else + fail "Restore should set namespace status back to ACTIVE" +fi + +REMOVE_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin")" +assert_code "Owner can remove namespace admin" "$REMOVE_MEMBER_RESPONSE" "0" + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/parallel-common.sh b/scripts/parallel-common.sh new file mode 100644 index 00000000..0669ca8f --- /dev/null +++ b/scripts/parallel-common.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +info() { + echo "INFO: $*" +} + +slugify() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' +} + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || fail "Run this script inside a git repository" +} + +git_common_dir() { + local root common_dir + root="$(repo_root)" + common_dir="$(git rev-parse --git-common-dir 2>/dev/null)" || fail "Unable to resolve the git common directory" + + if [ "${common_dir#/}" != "$common_dir" ]; then + printf '%s\n' "$common_dir" + return 0 + fi + + ( + cd "$root/$common_dir" && pwd + ) +} + +repo_name() { + basename "$(dirname "$(git_common_dir)")" +} + +current_branch() { + git rev-parse --abbrev-ref HEAD 2>/dev/null || fail "Unable to resolve the current branch" +} + +integration_task_from_branch() { + local branch="$1" + + case "$branch" in + agent/integration/*) + printf '%s\n' "${branch#agent/integration/}" + ;; + *) + return 1 + ;; + esac +} + +require_integration_task() { + local branch task + branch="$(current_branch)" + task="$(integration_task_from_branch "$branch")" || fail "Run this command inside the integration worktree on branch agent/integration/" + printf '%s\n' "$task" +} + +worktree_root() { + local root="${1:-$(repo_root)}" + printf '%s\n' "${PARALLEL_WORKTREE_ROOT:-$(dirname "$root")}" +} + +integration_dir_for_task() { + local task="$1" + local root + root="$(repo_root)" + printf '%s/%s-integration-%s\n' "$(worktree_root "$root")" "$(repo_name)" "$task" +} diff --git a/scripts/parallel-down.sh b/scripts/parallel-down.sh new file mode 100755 index 00000000..2b7d46f4 --- /dev/null +++ b/scripts/parallel-down.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./parallel-common.sh +source "$SCRIPT_DIR/parallel-common.sh" + +usage() { + cat <<'EOF' +Usage: parallel-down.sh + +Stops the integration worktree development stack. + +Run this inside an integration worktree on branch agent/integration/. +EOF +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +TASK_SLUG="$(require_integration_task)" +REPO_ROOT="$(repo_root)" + +info "Stopping integration stack for task $TASK_SLUG in $REPO_ROOT" +make -C "$REPO_ROOT" dev-all-down diff --git a/scripts/parallel-init.sh b/scripts/parallel-init.sh new file mode 100755 index 00000000..01a5bd54 --- /dev/null +++ b/scripts/parallel-init.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./parallel-common.sh +source "$SCRIPT_DIR/parallel-common.sh" + +usage() { + cat <<'EOF' +Usage: parallel-init.sh [base-ref] [worktree-root] + +Creates three sibling git worktrees for parallel Claude + Codex development: + - -claude- + - -codex- + - -integration- + +Arguments: + task-slug Required task identifier, for example legal-pages + base-ref Optional git ref to branch from (default: origin/main) + worktree-root Optional parent directory for new worktrees + +Example: + ./scripts/parallel-init.sh legal-pages origin/main /Users/me/workspace +EOF +} + +branch_in_use() { + local branch="$1" + git worktree list --porcelain | awk '/^branch / { print $2 }' | grep -Fxq "refs/heads/$branch" +} + +ensure_worktree() { + local label="$1" + local branch="$2" + local dir="$3" + local base_ref="$4" + + if [ -e "$dir/.git" ]; then + info "$label worktree already exists at $dir" + return 0 + fi + + if [ -e "$dir" ] && [ ! -e "$dir/.git" ]; then + fail "$label directory already exists but is not a git worktree: $dir" + fi + + if branch_in_use "$branch"; then + fail "$branch is already checked out in another worktree" + fi + + if git show-ref --verify --quiet "refs/heads/$branch"; then + info "Adding existing $label branch $branch at $dir" + git worktree add "$dir" "$branch" + else + info "Creating $label branch $branch from $base_ref at $dir" + git worktree add -b "$branch" "$dir" "$base_ref" + fi +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +TASK_INPUT="${1:-}" +BASE_REF="${2:-origin/main}" +WORKTREE_ROOT="${3:-}" + +if [ -z "$TASK_INPUT" ]; then + usage >&2 + exit 1 +fi + +REPO_ROOT="$(repo_root)" +REPO_NAME="$(repo_name)" +TASK_SLUG="$(slugify "$TASK_INPUT")" + +if [ -z "$TASK_SLUG" ]; then + fail "Task slug must contain at least one letter or number" +fi + +if [ -z "$WORKTREE_ROOT" ]; then + WORKTREE_ROOT="$(dirname "$REPO_ROOT")" +fi + +git rev-parse --verify --quiet "$BASE_REF^{commit}" >/dev/null || fail "Base ref not found: $BASE_REF" + +CLAUDE_BRANCH="agent/claude/$TASK_SLUG" +CODEX_BRANCH="agent/codex/$TASK_SLUG" +INTEGRATION_BRANCH="agent/integration/$TASK_SLUG" + +CLAUDE_DIR="$WORKTREE_ROOT/${REPO_NAME}-claude-$TASK_SLUG" +CODEX_DIR="$WORKTREE_ROOT/${REPO_NAME}-codex-$TASK_SLUG" +INTEGRATION_DIR="$WORKTREE_ROOT/${REPO_NAME}-integration-$TASK_SLUG" + +ensure_worktree "Claude" "$CLAUDE_BRANCH" "$CLAUDE_DIR" "$BASE_REF" +ensure_worktree "Codex" "$CODEX_BRANCH" "$CODEX_DIR" "$BASE_REF" +ensure_worktree "integration" "$INTEGRATION_BRANCH" "$INTEGRATION_DIR" "$BASE_REF" + +cat < + agent/codex/ + +Example: + ./scripts/parallel-sync.sh legal-pages + cd ../skillhub-integration-legal-pages && ./scripts/parallel-sync.sh + ./scripts/parallel-sync.sh legal-pages agent/claude/legal-pages agent/codex/legal-pages + +Environment: + PARALLEL_WORKTREE_ROOT Optional parent directory for parallel worktrees +EOF +} + +require_clean_worktree() { + local dir="$1" + if ! git -C "$dir" diff --quiet || ! git -C "$dir" diff --cached --quiet; then + fail "Integration worktree has uncommitted changes: $dir" + fi +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +TASK_INPUT="${1:-}" +if [ -n "$TASK_INPUT" ] && [[ "$TASK_INPUT" != */* ]]; then + shift + TASK_SLUG="$(slugify "$TASK_INPUT")" +else + TASK_SLUG="$(require_integration_task)" +fi + +REPO_ROOT="$(repo_root)" +INTEGRATION_DIR="$(integration_dir_for_task "$TASK_SLUG")" +INTEGRATION_BRANCH="agent/integration/$TASK_SLUG" + +if [ ! -e "$INTEGRATION_DIR/.git" ]; then + fail "Integration worktree not found: $INTEGRATION_DIR" +fi + +CURRENT_BRANCH="$(git -C "$INTEGRATION_DIR" rev-parse --abbrev-ref HEAD)" +if [ "$CURRENT_BRANCH" != "$INTEGRATION_BRANCH" ]; then + fail "Integration worktree is on $CURRENT_BRANCH, expected $INTEGRATION_BRANCH" +fi + +require_clean_worktree "$INTEGRATION_DIR" + +if [ "$#" -gt 0 ]; then + SOURCES=("$@") +else + SOURCES=("agent/claude/$TASK_SLUG" "agent/codex/$TASK_SLUG") +fi + +for source in "${SOURCES[@]}"; do + git rev-parse --verify --quiet "$source^{commit}" >/dev/null || fail "Source ref not found: $source" +done + +for source in "${SOURCES[@]}"; do + info "Merging $source into $INTEGRATION_BRANCH" + if ! git -C "$INTEGRATION_DIR" merge --no-ff --no-edit "$source"; then + echo "ERROR: Merge failed for $source. Resolve conflicts in $INTEGRATION_DIR and continue manually." >&2 + exit 1 + fi +done + +cat </dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split("."): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +echo "=== Promotion Workflow Smoke Test ===" +echo "Target: $BASE_URL" +echo "Slug: $SLUG" +echo + +bootstrap_csrf "$USER_COOKIE" "local-user" +bootstrap_csrf "$ADMIN_COOKIE" "local-admin" + +USER_CSRF="$(csrf_token "$USER_COOKIE")" +ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")" + +if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then + echo "Could not bootstrap CSRF tokens" + exit 1 +fi + +GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/namespaces/global")" +assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0" +GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")" + +CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces" \ + -d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")" +assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0" +NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")" + +cat > "$WORK_DIR/SKILL.md" <<'EOF' +--- +name: Promotion Smoke Skill +description: Promotion smoke test +version: 1.0.0 +--- +Body +EOF +(cd "$WORK_DIR" && zip -q skill.zip SKILL.md) + +PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -F "file=@$WORK_DIR/skill.zip;type=application/zip" \ + -F "visibility=PUBLIC" \ + "$BASE_URL/api/web/skills/$SLUG/publish")" +assert_code "Owner can publish a team skill" "$PUBLISH_RESPONSE" "0" +SKILL_ID="$(json_field "$PUBLISH_RESPONSE" "data.skillId")" +SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" "data.slug")" + +PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +assert_code "Admin can list pending namespace reviews" "$PENDING_REVIEWS_RESPONSE" "0" +REVIEW_ID="$(json_field "$PENDING_REVIEWS_RESPONSE" "data.items.0.id")" + +APPROVE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/reviews/$REVIEW_ID/approve" \ + -d '{"comment":"ok"}')" +assert_code "Admin can approve team skill review" "$APPROVE_REVIEW_RESPONSE" "0" + +SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")" +assert_code "Owner can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0" +VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.latestVersionId")" +CAN_SUBMIT_PROMOTION="$(json_field "$SKILL_DETAIL_RESPONSE" "data.canSubmitPromotion")" +if [[ "$CAN_SUBMIT_PROMOTION" == "True" || "$CAN_SUBMIT_PROMOTION" == "true" ]]; then + pass "Approved team skill is marked promotable" +else + fail "Approved team skill should expose canSubmitPromotion=true" +fi + +MY_SKILLS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/me/skills")" +assert_code "Owner can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0" +if JSON_INPUT="$MY_SKILLS_RESPONSE" python3 - "$SKILL_ID" <<'PY' +import json +import os +import sys + +skill_id = int(sys.argv[1]) +items = json.loads(os.environ["JSON_INPUT"])["data"] +match = next(item for item in items if item["id"] == skill_id) +raise SystemExit(0 if match["canSubmitPromotion"] and match["latestVersionId"] else 1) +PY +then + pass "My skills response exposes promotion submission fields" +else + fail "My skills response should expose latestVersionId and canSubmitPromotion" +fi + +SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/promotions" \ + -d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")" +assert_code "Owner can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0" + +PENDING_PROMOTIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + "$BASE_URL/api/web/promotions?status=PENDING")" +assert_code "Admin can list pending promotions" "$PENDING_PROMOTIONS_RESPONSE" "0" +if JSON_INPUT="$PENDING_PROMOTIONS_RESPONSE" python3 - "$SKILL_ID" <<'PY' +import json +import os +import sys + +skill_id = int(sys.argv[1]) +items = json.loads(os.environ["JSON_INPUT"])["data"]["items"] +raise SystemExit(0 if any(item["sourceSkillId"] == skill_id for item in items) else 1) +PY +then + pass "Pending promotions list contains the submitted team skill" +else + fail "Pending promotions list should include submitted team skill" +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/runtime.sh b/scripts/runtime.sh new file mode 100644 index 00000000..429f9ede --- /dev/null +++ b/scripts/runtime.sh @@ -0,0 +1,230 @@ +#!/bin/sh + +set -eu + +COMMAND="up" +if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then + COMMAND="$1" + shift +fi + +SKILLHUB_REF="${SKILLHUB_REF:-main}" +SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime" +SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}" +SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}" +SKILLHUB_ALIYUN_REGISTRY="${SKILLHUB_ALIYUN_REGISTRY:-crpi-ptu2rqimrigtq0qx.cn-hangzhou.personal.cr.aliyuncs.com}" +SKILLHUB_ALIYUN_NAMESPACE="${SKILLHUB_ALIYUN_NAMESPACE:-skill_hub}" +SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_MIRROR_REGISTRY:-}" +SKILLHUB_SERVER_IMAGE_VALUE="${SKILLHUB_SERVER_IMAGE:-}" +SKILLHUB_WEB_IMAGE_VALUE="${SKILLHUB_WEB_IMAGE:-}" +POSTGRES_IMAGE_VALUE="${POSTGRES_IMAGE:-}" +REDIS_IMAGE_VALUE="${REDIS_IMAGE:-}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || { echo "Missing value for --version" >&2; exit 1; } + SKILLHUB_VERSION_VALUE="$2" + shift 2 + ;; + --aliyun) + if [ -z "$SKILLHUB_ALIYUN_REGISTRY" ] || [ -z "$SKILLHUB_ALIYUN_NAMESPACE" ]; then + echo "SKILLHUB_ALIYUN_REGISTRY and SKILLHUB_ALIYUN_NAMESPACE must be configured for --aliyun" >&2 + exit 1 + fi + SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_ALIYUN_REGISTRY%/}/${SKILLHUB_ALIYUN_NAMESPACE}" + shift + ;; + --mirror-registry) + [ "$#" -ge 2 ] || { echo "Missing value for --mirror-registry" >&2; exit 1; } + SKILLHUB_MIRROR_REGISTRY_VALUE="$2" + shift 2 + ;; + --home) + [ "$#" -ge 2 ] || { echo "Missing value for --home" >&2; exit 1; } + SKILLHUB_HOME="$2" + shift 2 + ;; + --ref) + [ "$#" -ge 2 ] || { echo "Missing value for --ref" >&2; exit 1; } + SKILLHUB_REF="$2" + shift 2 + ;; + --server-image) + [ "$#" -ge 2 ] || { echo "Missing value for --server-image" >&2; exit 1; } + SKILLHUB_SERVER_IMAGE_VALUE="$2" + shift 2 + ;; + --web-image) + [ "$#" -ge 2 ] || { echo "Missing value for --web-image" >&2; exit 1; } + SKILLHUB_WEB_IMAGE_VALUE="$2" + shift 2 + ;; + --postgres-image) + [ "$#" -ge 2 ] || { echo "Missing value for --postgres-image" >&2; exit 1; } + POSTGRES_IMAGE_VALUE="$2" + shift 2 + ;; + --redis-image) + [ "$#" -ge 2 ] || { echo "Missing value for --redis-image" >&2; exit 1; } + REDIS_IMAGE_VALUE="$2" + shift 2 + ;; + --help|-h) + cat < Use a specific image tag, for example v0.1.0 + --aliyun Use the configured Aliyun mirror registry + --mirror-registry Use mirrored images from / + --home Store runtime files in a specific directory + --ref Download runtime files from a specific Git ref + --server-image Override backend image repository + --web-image Override frontend image repository + --postgres-image Override PostgreSQL image + --redis-image Override Redis image +EOF + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + exit 1 + ;; + esac +done + +SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" +COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml" +ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example" +ENV_FILE="$SKILLHUB_HOME/.env.release" + +find_compose() { + if docker compose version >/dev/null 2>&1; then + echo "docker compose" + return 0 + fi + + if command -v docker-compose >/dev/null 2>&1; then + echo "docker-compose" + return 0 + fi + + echo "Docker Compose is required." >&2 + exit 1 +} + +download_file() { + src="$1" + dest="$2" + tmp="$dest.tmp" + curl -fsSL "$src" -o "$tmp" + mv "$tmp" "$dest" +} + +set_env_value() { + key="$1" + value="$2" + + if [ ! -f "$ENV_FILE" ]; then + return 0 + fi + + tmp="$ENV_FILE.tmp" + if grep -q "^$key=" "$ENV_FILE"; then + sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp" + else + cat "$ENV_FILE" >"$tmp" + printf '%s=%s\n' "$key" "$value" >>"$tmp" + fi + mv "$tmp" "$ENV_FILE" +} + +prepare_runtime_files() { + mkdir -p "$SKILLHUB_HOME" + download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE" + download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE" + + if [ ! -f "$ENV_FILE" ]; then + cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" + fi + + if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then + mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}" + if [ -z "$POSTGRES_IMAGE_VALUE" ]; then + POSTGRES_IMAGE_VALUE="$mirror_registry/postgres:16-alpine" + fi + if [ -z "$REDIS_IMAGE_VALUE" ]; then + REDIS_IMAGE_VALUE="$mirror_registry/redis:7-alpine" + fi + if [ -z "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then + SKILLHUB_SERVER_IMAGE_VALUE="$mirror_registry/skillhub-server" + fi + if [ -z "$SKILLHUB_WEB_IMAGE_VALUE" ]; then + SKILLHUB_WEB_IMAGE_VALUE="$mirror_registry/skillhub-web" + fi + fi + + if [ -n "$SKILLHUB_VERSION_VALUE" ]; then + set_env_value "SKILLHUB_VERSION" "$SKILLHUB_VERSION_VALUE" + fi + + if [ -n "$POSTGRES_IMAGE_VALUE" ]; then + set_env_value "POSTGRES_IMAGE" "$POSTGRES_IMAGE_VALUE" + fi + + if [ -n "$REDIS_IMAGE_VALUE" ]; then + set_env_value "REDIS_IMAGE" "$REDIS_IMAGE_VALUE" + fi + + if [ -n "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then + set_env_value "SKILLHUB_SERVER_IMAGE" "$SKILLHUB_SERVER_IMAGE_VALUE" + fi + + if [ -n "$SKILLHUB_WEB_IMAGE_VALUE" ]; then + set_env_value "SKILLHUB_WEB_IMAGE" "$SKILLHUB_WEB_IMAGE_VALUE" + fi +} + +run_compose() { + compose_cmd="$(find_compose)" + # shellcheck disable=SC2086 + $compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +prepare_runtime_files + +case "$COMMAND" in + up) + run_compose up -d + cat <&2 + echo "Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]" >&2 + exit 1 + ;; +esac diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..add4c748 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE_JAR="$(mktemp)" +USERNAME="smoketest_$(date +%s)" +EMAIL="${USERNAME}@example.com" +PASSWORD="Smoke@2026" +NEW_PASSWORD="Smoke@2027" + +cleanup() { + rm -f "$COOKIE_JAR" +} + +trap cleanup EXIT + +check() { + local desc="$1" + local url="$2" + local expected="$3" + local status + status="$(curl --retry 3 --retry-delay 1 --max-time 10 -s -o /dev/null -w "%{http_code}" "$url" || true)" + if [[ "$status" == "$expected" ]]; then + echo "PASS: $desc (HTTP $status)" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc (expected $expected, got $status)" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== SkillHub Smoke Test ===" +echo "Target: $BASE_URL" +echo + +check "Health endpoint" "$BASE_URL/actuator/health" "200" +check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" +check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" +check "Auth required" "$BASE_URL/api/v1/auth/me" "401" + +curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null +CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)" + +REGISTER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/register" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"email\":\"$EMAIL\"}" || true)" +if [[ "$REGISTER_STATUS" == "200" ]]; then + echo "PASS: Register (HTTP $REGISTER_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Register (got $REGISTER_STATUS)" + FAIL=$((FAIL + 1)) +fi + +AUTH_ME_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)" +if [[ "$AUTH_ME_STATUS" == "200" ]]; then + echo "PASS: Auth me with session (HTTP $AUTH_ME_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me with session (got $AUTH_ME_STATUS)" + FAIL=$((FAIL + 1)) +fi + +CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/change-password" \ + -b "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"currentPassword\":\"$PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}" || true)" +if [[ "$CHANGE_PASSWORD_STATUS" == "200" ]]; then + echo "PASS: Change password (HTTP $CHANGE_PASSWORD_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Change password (got $CHANGE_PASSWORD_STATUS)" + FAIL=$((FAIL + 1)) +fi + +LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" || true)" +if [[ "$LOGOUT_STATUS" == "302" || "$LOGOUT_STATUS" == "200" || "$LOGOUT_STATUS" == "204" ]]; then + echo "PASS: Logout (HTTP $LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Logout (got $LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + +POST_LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)" +if [[ "$POST_LOGOUT_STATUS" == "401" ]]; then + echo "PASS: Auth me after logout (HTTP $POST_LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me after logout (got $POST_LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh new file mode 100755 index 00000000..faa944a4 --- /dev/null +++ b/scripts/validate-release-config.sh @@ -0,0 +1,180 @@ +#!/bin/sh +set -eu + +ENV_FILE="${1:-.env.release}" + +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: env file not found: $ENV_FILE" >&2 + exit 1 +fi + +while IFS= read -r raw_line || [ -n "$raw_line" ]; do + line=$(printf '%s' "$raw_line" | tr -d '\r') + case "$line" in + ""|\#*) continue ;; + esac + export "$line" +done < "$ENV_FILE" + +errors=0 +warnings=0 + +error() { + errors=$((errors + 1)) + echo "ERROR: $*" >&2 +} + +warn() { + warnings=$((warnings + 1)) + echo "WARN: $*" >&2 +} + +require_non_empty() { + var_name="$1" + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + error "$var_name is required" + fi +} + +reject_values() { + var_name="$1" + shift + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + for bad in "$@"; do + if [ "$var_value" = "$bad" ]; then + error "$var_name still uses placeholder/default value: $bad" + return 0 + fi + done +} + +validate_url() { + var_name="$1" + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + case "$var_value" in + http://*|https://*) ;; + *) error "$var_name must start with http:// or https://" ;; + esac +} + +validate_no_trailing_slash() { + var_name="$1" + eval "var_value=\${$var_name:-}" + case "$var_value" in + */) error "$var_name must not have a trailing slash" ;; + esac +} + +validate_boolean() { + var_name="$1" + eval "var_value=\${$var_name:-}" + case "$var_value" in + ""|true|false) ;; + *) error "$var_name must be true or false" ;; + esac +} + +validate_port() { + var_name="$1" + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + case "$var_value" in + *[!0-9]*|"") error "$var_name must be numeric" ;; + *) + if [ "$var_value" -lt 1 ] || [ "$var_value" -gt 65535 ]; then + error "$var_name must be between 1 and 65535" + fi + ;; + esac +} + +require_non_empty SKILLHUB_PUBLIC_BASE_URL +validate_url SKILLHUB_PUBLIC_BASE_URL +validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL + +reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" +reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" +reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me" +reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me" + +validate_boolean SESSION_COOKIE_SECURE +validate_boolean BOOTSTRAP_ADMIN_ENABLED +validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE +validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET + +validate_port POSTGRES_PORT +validate_port REDIS_PORT +validate_port API_PORT +validate_port WEB_PORT + +require_non_empty POSTGRES_DB +require_non_empty POSTGRES_USER +require_non_empty POSTGRES_PASSWORD + +storage_provider="${SKILLHUB_STORAGE_PROVIDER:-}" +case "$storage_provider" in + s3) + require_non_empty SKILLHUB_STORAGE_S3_ENDPOINT + require_non_empty SKILLHUB_STORAGE_S3_BUCKET + require_non_empty SKILLHUB_STORAGE_S3_ACCESS_KEY + require_non_empty SKILLHUB_STORAGE_S3_SECRET_KEY + require_non_empty SKILLHUB_STORAGE_S3_REGION + validate_url SKILLHUB_STORAGE_S3_ENDPOINT + validate_url SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT + ;; + local) + warn "SKILLHUB_STORAGE_PROVIDER=local is only suitable for non-production or temporary validation" + ;; + "") + error "SKILLHUB_STORAGE_PROVIDER is required" + ;; + *) + error "SKILLHUB_STORAGE_PROVIDER must be either local or s3" + ;; +esac + +if [ -n "${SKILLHUB_WEB_API_BASE_URL:-}" ]; then + validate_url SKILLHUB_WEB_API_BASE_URL + validate_no_trailing_slash SKILLHUB_WEB_API_BASE_URL +fi + +if [ -n "${DEVICE_AUTH_VERIFICATION_URI:-}" ]; then + validate_url DEVICE_AUTH_VERIFICATION_URI +fi + +if [ "${SESSION_COOKIE_SECURE:-true}" != "true" ]; then + warn "SESSION_COOKIE_SECURE is not true; only acceptable behind plain HTTP during temporary local verification" +fi + +if [ "${POSTGRES_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then + warn "POSTGRES_BIND_ADDRESS is not 127.0.0.1; confirm database exposure is intended" +fi + +if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then + warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended" +fi + +oauth_id="${OAUTH2_GITHUB_CLIENT_ID:-}" +oauth_secret="${OAUTH2_GITHUB_CLIENT_SECRET:-}" +if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then + error "OAUTH2_GITHUB_CLIENT_SECRET is required when OAUTH2_GITHUB_CLIENT_ID is set" +fi +if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then + error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set" +fi + +if [ "$errors" -gt 0 ]; then + echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2 + exit 1 +fi + +echo "Release config validation passed with $warnings warning(s)." diff --git a/server/.dockerignore b/server/.dockerignore index b7da8964..f993bd6a 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,4 +1,7 @@ -**/target/ +# Exclude target for multi-stage build (Dockerfile) +# But allow it for runtime build (Dockerfile.runtime) which needs pre-built JAR +# **/target/ + **/.idea/ **/*.iml .git/ diff --git a/server/Dockerfile b/server/Dockerfile index dbac3589..a57eaaef 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -24,7 +24,10 @@ WORKDIR /app COPY --from=build /app/skillhub-app/target/*.jar app.jar -RUN chown -R app:app /app +# Create storage directory and set permissions +RUN mkdir -p /var/lib/skillhub/storage && \ + chown -R app:app /app /var/lib/skillhub/storage + USER app EXPOSE 8080 diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev new file mode 100644 index 00000000..77f96fcc --- /dev/null +++ b/server/Dockerfile.dev @@ -0,0 +1,19 @@ +# Development Dockerfile +# Expects pre-built JAR at skillhub-app/target/*.jar +# Used by: make staging (local development workflow) +FROM eclipse-temurin:21-jre-alpine + +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app + +# Copy pre-built JAR from local build (relative to server/ directory) +COPY ./skillhub-app/target/*.jar app.jar + +RUN chown -R app:app /app +USER app + +EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=3s \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] diff --git a/server/pom.xml b/server/pom.xml index 6a167777..22985624 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -14,7 +14,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 pom diff --git a/server/scripts/run-dev-app.sh b/server/scripts/run-dev-app.sh new file mode 100755 index 00000000..843bf182 --- /dev/null +++ b/server/scripts/run-dev-app.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SERVER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE="${SPRING_PROFILES_ACTIVE:-local}" + +cd "$SERVER_DIR" + +./mvnw -pl skillhub-app -am package -DskipTests >/dev/null + +APP_JAR="$(find skillhub-app/target -maxdepth 1 -type f -name 'skillhub-app-*.jar' ! -name '*.original' | head -n 1)" +if [[ -z "$APP_JAR" ]]; then + echo "Could not locate packaged skillhub-app jar under skillhub-app/target" >&2 + exit 1 +fi + +exec "${JAVA_BIN:-java}" -jar "$APP_JAR" --spring.profiles.active="$PROFILE" "$@" diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 6689adc8..f7ddf6ca 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -8,7 +8,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 skillhub-app @@ -22,6 +22,10 @@ org.springframework.boot spring-boot-starter-actuator + + io.micrometer + micrometer-registry-prometheus + org.springdoc springdoc-openapi-starter-webmvc-ui @@ -69,6 +73,12 @@ org.springframework.session spring-session-data-redis + + org.springframework.boot + spring-boot-devtools + runtime + true + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java new file mode 100644 index 00000000..f8e7fab5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java @@ -0,0 +1,112 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Seeds a default bootstrap admin account for any runtime profile. + * Idempotent: skips if admin credential already exists. + */ +@Component +public class BootstrapAdminInitializer implements ApplicationRunner { + private static final Logger log = LoggerFactory.getLogger(BootstrapAdminInitializer.class); + + private final BootstrapAdminProperties bootstrapAdminProperties; + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + + public BootstrapAdminInitializer(BootstrapAdminProperties bootstrapAdminProperties, + UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + this.bootstrapAdminProperties = bootstrapAdminProperties; + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!bootstrapAdminProperties.isEnabled()) { + log.info("Bootstrap admin is disabled"); + return; + } + if (localCredentialRepository.existsByUsernameIgnoreCase(bootstrapAdminProperties.getUsername())) { + log.info("Bootstrap admin already exists, skipping"); + return; + } + + // 1. Create admin user account + UserAccount admin = userAccountRepository.findById(bootstrapAdminProperties.getUserId()) + .orElseGet(() -> userAccountRepository.save( + new UserAccount( + bootstrapAdminProperties.getUserId(), + bootstrapAdminProperties.getDisplayName(), + bootstrapAdminProperties.getEmail(), + null + ) + )); + admin.setDisplayName(bootstrapAdminProperties.getDisplayName()); + admin.setEmail(bootstrapAdminProperties.getEmail()); + admin = userAccountRepository.save(admin); + + // 2. Create local credential (username/password) + localCredentialRepository.save( + new LocalCredential( + admin.getId(), + bootstrapAdminProperties.getUsername(), + passwordEncoder.encode(bootstrapAdminProperties.getPassword()) + ) + ); + + // 3. Assign SUPER_ADMIN role + Role superAdmin = roleRepository.findByCode("SUPER_ADMIN") + .orElseThrow(() -> new IllegalStateException("Missing built-in role: SUPER_ADMIN")); + boolean hasRole = userRoleBindingRepository.findByUserId(admin.getId()).stream() + .anyMatch(b -> b.getRole().getCode().equals("SUPER_ADMIN")); + if (!hasRole) { + userRoleBindingRepository.save(new UserRoleBinding(admin.getId(), superAdmin)); + } + + // 4. Ensure global namespace + membership + Namespace globalNs = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(globalNs.getId(), admin.getId()).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); + } + + log.info("Bootstrap admin initialized for account: {}", bootstrapAdminProperties.getUsername()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java new file mode 100644 index 00000000..11aadcdf --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.bootstrap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.bootstrap.admin") +public class BootstrapAdminProperties { + private boolean enabled = false; + private String userId = "docker-admin"; + private String username = "admin"; + private String password = "ChangeMe!2026"; + private String displayName = "Admin"; + private String email = "admin@skillhub.local"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java new file mode 100644 index 00000000..b78a871c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Profile("local") +public class LocalDevDataInitializer implements ApplicationRunner { + + public static final String LOCAL_USER_ID = "local-user"; + public static final String LOCAL_ADMIN_ID = "local-admin"; + + private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class); + + private final UserAccountRepository userAccountRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + + public LocalDevDataInitializer(UserAccountRepository userAccountRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository) { + this.userAccountRepository = userAccountRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + UserAccount localUser = ensureUser( + LOCAL_USER_ID, + "Local Developer", + "local-user@example.test" + ); + UserAccount localAdmin = ensureUser( + LOCAL_ADMIN_ID, + "Local Admin", + "local-admin@example.test" + ); + + Namespace globalNamespace = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER); + ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER); + ensureRole(localAdmin.getId(), "SUPER_ADMIN"); + + log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID); + } + + private UserAccount ensureUser(String userId, String displayName, String email) { + return userAccountRepository.findById(userId) + .map(existing -> { + existing.setDisplayName(displayName); + existing.setEmail(email); + existing.setStatus(UserStatus.ACTIVE); + return userAccountRepository.save(existing); + }) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(userId, displayName, email, null) + )); + } + + private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) { + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseGet(() -> new NamespaceMember(namespaceId, userId, role)); + if (member.getRole() != role) { + member.setRole(role); + } + namespaceMemberRepository.save(member); + } + + private void ensureRole(String userId, String roleCode) { + boolean exists = userRoleBindingRepository.findByUserId(userId).stream() + .map(binding -> binding.getRole().getCode()) + .anyMatch(roleCode::equals); + if (exists) { + return; + } + + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode)); + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index de278d66..dbd179f4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -1,30 +1,92 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; +import com.iflytek.skillhub.compat.dto.ClawHubDeleteResponse; +import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; +import com.iflytek.skillhub.compat.dto.ClawHubStarResponse; +import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +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.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; @RestController -@RequestMapping("/api/compat/v1") +@RequestMapping("/api/v1") public class ClawHubCompatController { private final CanonicalSlugMapper mapper; private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; + private final MultipartPackageExtractor multipartPackageExtractor; + private final AuditLogService auditLogService; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillStarService skillStarService; + private final SkillSlugResolutionService skillSlugResolutionService; - public ClawHubCompatController(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService) { + public ClawHubCompatController(CanonicalSlugMapper mapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, + MultipartPackageExtractor multipartPackageExtractor, + AuditLogService auditLogService, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, + SkillStarService skillStarService, + SkillSlugResolutionService skillSlugResolutionService) { this.mapper = mapper; this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; + this.multipartPackageExtractor = multipartPackageExtractor; + this.auditLogService = auditLogService; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillStarService = skillStarService; + this.skillSlugResolutionService = skillSlugResolutionService; } + @RateLimit(category = "search", authenticated = 60, anonymous = 20) @GetMapping("/search") public ClawHubSearchResponse search( @RequestParam String q, @@ -42,35 +104,395 @@ public ClawHubSearchResponse search( userNsRoles ); - List items = response.items().stream() - .map(item -> new ClawHubSkillItem( - mapper.toCanonical(item.namespace(), item.slug()), - item.summary(), - item.latestVersion(), - item.starCount())) + List results = response.items().stream() + .map(this::toSearchResult) .toList(); - return new ClawHubSearchResponse(items); + return new ClawHubSearchResponse(results); } + private ClawHubSearchResponse.ClawHubSearchResult toSearchResult(SkillSummaryResponse item) { + Long updatedAtEpoch = item.updatedAt() != null + ? item.updatedAt().toEpochMilli() + : null; + return new ClawHubSearchResponse.ClawHubSearchResult( + mapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + item.publishedVersion() != null ? item.publishedVersion().version() : null, + calculateScore(item), + updatedAtEpoch + ); + } + + private double calculateScore(SkillSummaryResponse item) { + // Simple score calculation based on stars and downloads + int starScore = item.starCount() != null ? item.starCount() * 10 : 0; + long downloadScore = item.downloadCount() != null ? item.downloadCount() : 0; + return (starScore + downloadScore) / 100.0; + } + + @RateLimit(category = "resolve", authenticated = 60, anonymous = 20) + @GetMapping("/resolve") + public ClawHubResolveResponse resolveByQuery( + @RequestParam String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + // For resolve endpoint with query params, slug is just the skill slug without namespace + // We need to find the skill by slug (this is a simplification - in real world you'd need more context) + Skill skill = skillRepository.findBySlug(slug).stream().findFirst() + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); + + Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); + + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + ns.getSlug(), + skill.getSlug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + hash, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + + return new ClawHubResolveResponse(matchVersion, latestVersion); + } + + @RateLimit(category = "resolve", authenticated = 60, anonymous = 20) @GetMapping("/resolve/{canonicalSlug}") public ClawHubResolveResponse resolve( @PathVariable String canonicalSlug, - @RequestParam(defaultValue = "latest") String version) { + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + null, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + + return new ClawHubResolveResponse(matchVersion, latestVersion); + } + + @RateLimit(category = "download", authenticated = 60, anonymous = 20) + @GetMapping("/download/{canonicalSlug}") + public ResponseEntity downloadByPath(@PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - return new ClawHubResolveResponse( - canonicalSlug, - version, - "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + String location = "latest".equals(version) + ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } + + @RateLimit(category = "download", authenticated = 60, anonymous = 20) + @GetMapping("/download") + public ResponseEntity downloadByQuery(@RequestParam String slug, + @RequestParam(defaultValue = "latest") String version) { + // For query param version, slug is just the skill slug without namespace + Skill skill = skillRepository.findBySlug(slug).stream().findFirst() + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); + Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); + String location = "latest".equals(version) + ? "/api/v1/skills/" + ns.getSlug() + "/" + skill.getSlug() + "/download" + : "/api/v1/skills/" + ns.getSlug() + "/" + skill.getSlug() + "/versions/" + version + "/download"; + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } + + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) + @GetMapping("/skills") + public ClawHubSkillListResponse listSkills( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int limit, + @RequestParam(required = false) String sort, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + // Use search with empty query to list skills + String sortBy = sort != null ? sort : "newest"; + SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + "", + null, + sortBy, + page, + limit, + userId, + userNsRoles + ); + + List items = response.items().stream() + .map(this::toSkillListItem) + .toList(); + + // Calculate nextCursor: if there are more results, return next page number as cursor + String nextCursor = null; + long totalResults = response.total(); + long currentOffset = (long) page * limit; + if (currentOffset + items.size() < totalResults) { + nextCursor = String.valueOf(page + 1); + } + + return new ClawHubSkillListResponse(items, nextCursor); + } + + private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) { + long createdAt = 0; + long updatedAt = item.updatedAt() != null + ? item.updatedAt().toEpochMilli() + : 0; + + ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null; + if (item.publishedVersion() != null) { + latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion( + item.publishedVersion().version(), + updatedAt, // Use skill's updatedAt as version createdAt + "", // changelog not available in summary + null // license not available in summary + ); + } + + // Build stats map with non-null values + Map stats = new java.util.HashMap<>(); + if (item.downloadCount() != null) { + stats.put("downloads", item.downloadCount()); + } + if (item.starCount() != null) { + stats.put("stars", item.starCount()); + } + + return new ClawHubSkillListResponse.SkillListItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + Map.of(), // tags + stats, + createdAt, + updatedAt, + latestVersion ); } + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) + @GetMapping("/skills/{canonicalSlug}") + public ClawHubSkillResponse getSkill( + @PathVariable String canonicalSlug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + + Namespace ns = namespaceRepository.findBySlug(coord.namespace()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); + Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), userId); + + SkillVersion latestVersionEntity = null; + if (skill.getLatestVersionId() != null) { + latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + } + + ClawHubSkillResponse.SkillInfo skillInfo = null; + ClawHubSkillResponse.VersionInfo versionInfo = null; + + if (skill.getId() != null) { + long createdAt = skill.getCreatedAt() != null + ? skill.getCreatedAt().toEpochMilli() + : 0; + long updatedAt = skill.getUpdatedAt() != null + ? skill.getUpdatedAt().toEpochMilli() + : 0; + skillInfo = new ClawHubSkillResponse.SkillInfo( + mapper.toCanonical(coord.namespace(), coord.slug()), + skill.getDisplayName(), + skill.getSummary(), + Map.of(), // tags + Map.of(), // stats + createdAt, + updatedAt + ); + + if (latestVersionEntity != null) { + long versionCreatedAt = latestVersionEntity.getPublishedAt() != null + ? latestVersionEntity.getPublishedAt().toEpochMilli() + : 0; + versionInfo = new ClawHubSkillResponse.VersionInfo( + latestVersionEntity.getVersion(), + versionCreatedAt, + latestVersionEntity.getChangelog() == null ? "" : latestVersionEntity.getChangelog(), + null // license + ); + } + } + + // Owner info - we don't have this readily available, return null + ClawHubSkillResponse.OwnerInfo ownerInfo = null; + + // Moderation info - not implemented yet + ClawHubSkillResponse.ModerationInfo moderationInfo = new ClawHubSkillResponse.ModerationInfo( + false, false, "clean", new String[0], null, null, null + ); + + return new ClawHubSkillResponse(skillInfo, versionInfo, ownerInfo, moderationInfo); + } + + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) + @DeleteMapping("/skills/{canonicalSlug}") + public ClawHubDeleteResponse deleteSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + // Note: Full delete not implemented yet, just return ok for compatibility + return new ClawHubDeleteResponse(); + } + + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) + @PostMapping("/skills/{canonicalSlug}/undelete") + public ClawHubDeleteResponse undeleteSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + // Note: Undelete not implemented yet, just return ok for compatibility + return new ClawHubDeleteResponse(); + } + + @RateLimit(category = "stars", authenticated = 60, anonymous = 20) + @PostMapping("/stars/{canonicalSlug}") + public ClawHubStarResponse starSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + Namespace ns = namespaceRepository.findBySlug(coord.namespace()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); + Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId()); + + boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId()); + skillStarService.star(skill.getId(), principal.userId()); + + return new ClawHubStarResponse(true, alreadyStarred); + } + + @RateLimit(category = "stars", authenticated = 60, anonymous = 20) + @DeleteMapping("/stars/{canonicalSlug}") + public ClawHubUnstarResponse unstarSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + Namespace ns = namespaceRepository.findBySlug(coord.namespace()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); + Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId()); + + boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId()); + skillStarService.unstar(skill.getId(), principal.userId()); + + return new ClawHubUnstarResponse(true, alreadyUnstarred); + } + + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) + @PostMapping("/skills") + public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson, + @RequestParam("files") MultipartFile[] files, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { + MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson); + String namespace = determineNamespace(principal, extracted.payload()); + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + extracted.entries(), + principal.userId(), + SkillVisibility.PUBLIC, + principal.platformRoles() + ); + auditLogService.record( + principal.userId(), + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}" + ); + return new ClawHubPublishResponse( + result.skillId().toString(), + result.version().getId().toString() + ); + } + + @RateLimit(category = "publish", authenticated = 60, anonymous = 20) + @PostMapping("/publish") + public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, + @RequestParam("namespace") String namespace, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + zipPackageExtractor.extract(file), + principal.userId(), + SkillVisibility.PUBLIC, + principal.platformRoles() + ); + auditLogService.record( + principal.userId(), + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); + return new ClawHubPublishResponse( + result.skillId().toString(), + result.version().getId().toString() + ); + } + + private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) { + // Use "global" namespace by default for compatibility + return "global"; + } + + @RateLimit(category = "whoami", authenticated = 60, anonymous = 20) @GetMapping("/whoami") public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { return new ClawHubWhoamiResponse( principal.userId(), principal.displayName(), - principal.email() + principal.avatarUrl() ); } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + try { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED); + } catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException ex) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java new file mode 100644 index 00000000..1a697f18 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -0,0 +1,214 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.compat.dto.ClawHubRegistryModeration; +import com.iflytek.skillhub.compat.dto.ClawHubRegistryOwner; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchItem; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkill; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillVersion; +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.service.SkillQueryService; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class ClawHubRegistryFacade { + + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + + private final CanonicalSlugMapper canonicalSlugMapper; + private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final UserAccountRepository userAccountRepository; + + public ClawHubRegistryFacade( + CanonicalSlugMapper canonicalSlugMapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + UserAccountRepository userAccountRepository) { + this.canonicalSlugMapper = canonicalSlugMapper; + this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.userAccountRepository = userAccountRepository; + } + + public ClawHubRegistrySearchResponse search( + String keyword, + int limit, + String userId, + Map userNsRoles) { + int boundedLimit = clampLimit(limit); + List items = skillSearchAppService.search( + keyword, + null, + "relevance", + 0, + boundedLimit, + userId, + normalizeRoles(userNsRoles)) + .items(); + + List results = buildSearchResults(items); + return new ClawHubRegistrySearchResponse(results); + } + + public ClawHubRegistrySkillResponse getSkill( + String canonicalSlug, + String userId, + Map userNsRoles) { + SkillCoordinate coordinate = canonicalSlugMapper.fromCanonical(canonicalSlug); + SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( + coordinate.namespace(), + coordinate.slug(), + userId, + normalizeRoles(userNsRoles)); + + Skill skill = skillRepository.findById(detail.id()) + .orElseThrow(() -> new IllegalStateException("Skill unexpectedly missing: " + canonicalSlug)); + + ClawHubRegistrySkill payload = new ClawHubRegistrySkill( + canonicalSlugMapper.toCanonical(coordinate.namespace(), detail.slug()), + normalizeDisplayName(detail.displayName(), canonicalSlug), + detail.summary(), + List.of(), + Map.of(), + toEpochMillis(skill.getCreatedAt()), + toEpochMillis(skill.getUpdatedAt()) + ); + + ClawHubRegistrySkillVersion latestVersion = buildLatestVersion(skill, detail.publishedVersion()); + ClawHubRegistryOwner owner = buildOwner(skill.getOwnerId()); + + return new ClawHubRegistrySkillResponse( + payload, + latestVersion, + owner, + ClawHubRegistryModeration.clean() + ); + } + + public String resolveDownloadUrl( + String canonicalSlug, + String version, + String userId, + Map userNsRoles) { + SkillCoordinate coordinate = canonicalSlugMapper.fromCanonical(canonicalSlug); + String normalizedVersion = normalizeVersion(version); + return skillQueryService.resolveVersion( + coordinate.namespace(), + coordinate.slug(), + normalizedVersion, + null, + null, + userId, + normalizeRoles(userNsRoles)) + .downloadUrl(); + } + + private List buildSearchResults(List items) { + return java.util.stream.IntStream.range(0, items.size()) + .mapToObj(index -> toSearchItem(items.get(index), index)) + .toList(); + } + + private ClawHubRegistrySearchItem toSearchItem(SkillSummaryResponse item, int index) { + String canonicalSlug = canonicalSlugMapper.toCanonical(item.namespace(), item.slug()); + return new ClawHubRegistrySearchItem( + canonicalSlug, + normalizeDisplayName(item.displayName(), canonicalSlug), + item.summary(), + item.publishedVersion() != null ? item.publishedVersion().version() : null, + scoreFor(index), + toEpochMillis(item.updatedAt()) + ); + } + + private ClawHubRegistrySkillVersion buildLatestVersion( + Skill skill, + com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection projection) { + if (projection == null || projection.version() == null || projection.version().isBlank()) { + return null; + } + + Optional latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), projection.version()); + if (latestVersion.isEmpty()) { + return new ClawHubRegistrySkillVersion(projection.version(), 0L, "", null); + } + + SkillVersion entity = latestVersion.get(); + Instant createdAt = entity.getPublishedAt() != null ? entity.getPublishedAt() : entity.getCreatedAt(); + return new ClawHubRegistrySkillVersion( + entity.getVersion(), + toEpochMillis(createdAt), + entity.getChangelog() != null ? entity.getChangelog() : "", + null + ); + } + + private ClawHubRegistryOwner buildOwner(String ownerId) { + if (ownerId == null || ownerId.isBlank()) { + return ClawHubRegistryOwner.empty(); + } + + return userAccountRepository.findById(ownerId) + .map(user -> new ClawHubRegistryOwner( + null, + user.getDisplayName(), + user.getAvatarUrl())) + .orElseGet(ClawHubRegistryOwner::empty); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); + } + + private int clampLimit(int limit) { + if (limit <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(limit, MAX_LIMIT); + } + + private String normalizeDisplayName(String displayName, String fallback) { + if (displayName == null || displayName.isBlank()) { + return fallback; + } + return displayName; + } + + private String normalizeVersion(String version) { + if (version == null || version.isBlank()) { + return null; + } + return version; + } + + private double scoreFor(int index) { + return Math.max(0.001d, 1.0d - (index * 0.001d)); + } + + private long toEpochMillis(Instant timestamp) { + if (timestamp == null) { + return 0L; + } + return timestamp.toEpochMilli(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java new file mode 100644 index 00000000..66a632e9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.compat; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class ClawHubRegistrySecurityConfig { + + @Bean + @Order(0) + public SecurityFilterChain clawHubRegistryFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/v1/search", "/api/v1/download", "/api/v1/skills/*") + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .requestCache(cache -> cache.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java index 0737ebd5..4ddfb79d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java @@ -10,6 +10,6 @@ public class WellKnownController { @GetMapping("/.well-known/clawhub.json") public Map clawhubConfig() { - return Map.of("apiBase", "/api/compat/v1"); + return Map.of("apiBase", "/api/v1"); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java new file mode 100644 index 00000000..b673ec10 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubDeleteResponse( + boolean ok +) { + public ClawHubDeleteResponse() { + this(true); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java index b117ebd9..ff82b79b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.compat.dto; public record ClawHubPublishResponse( - String canonicalSlug, - String version, - String status -) {} + boolean ok, + String skillId, + String versionId +) { + public ClawHubPublishResponse(String skillId, String versionId) { + this(true, skillId, versionId); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java new file mode 100644 index 00000000..2f450ff1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubRegistryModeration( + boolean isSuspicious, + boolean isMalwareBlocked, + String verdict, + List reasonCodes, + Long updatedAt, + String engineVersion, + String summary +) { + public static ClawHubRegistryModeration clean() { + return new ClawHubRegistryModeration( + false, + false, + "clean", + List.of(), + null, + null, + null + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java new file mode 100644 index 00000000..3819552a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.compat.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.ALWAYS) +public record ClawHubRegistryOwner( + String handle, + String displayName, + String image +) { + public static ClawHubRegistryOwner empty() { + return new ClawHubRegistryOwner(null, null, null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java new file mode 100644 index 00000000..960a6f5b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubRegistrySearchItem( + String slug, + String displayName, + String summary, + String version, + double score, + long updatedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java new file mode 100644 index 00000000..9e900562 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubRegistrySearchResponse(List results) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java new file mode 100644 index 00000000..15819875 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; +import java.util.Map; + +public record ClawHubRegistrySkill( + String slug, + String displayName, + String summary, + List tags, + Map stats, + long createdAt, + long updatedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java new file mode 100644 index 00000000..771fcea2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.compat.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.ALWAYS) +public record ClawHubRegistrySkillResponse( + ClawHubRegistrySkill skill, + ClawHubRegistrySkillVersion latestVersion, + ClawHubRegistryOwner owner, + ClawHubRegistryModeration moderation +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java new file mode 100644 index 00000000..686f72c6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubRegistrySkillVersion( + String version, + long createdAt, + String changelog, + Object license +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java index 79427437..5ba9d35c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java @@ -1,7 +1,10 @@ package com.iflytek.skillhub.compat.dto; public record ClawHubResolveResponse( - String canonicalSlug, - String version, - String downloadUrl -) {} + VersionInfo match, + VersionInfo latestVersion +) { + public record VersionInfo( + String version + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java index ee03acce..a6b49ebd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java @@ -2,4 +2,15 @@ import java.util.List; -public record ClawHubSearchResponse(List items) {} +public record ClawHubSearchResponse( + List results +) { + public record ClawHubSearchResult( + String slug, + String displayName, + String summary, + String version, + double score, + Long updatedAt + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java deleted file mode 100644 index 3211adbc..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.iflytek.skillhub.compat.dto; - -public record ClawHubSkillItem( - String canonicalSlug, - String description, - String latestVersion, - int starCount -) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java new file mode 100644 index 00000000..d1f7f376 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubSkillListResponse( + List items, + String nextCursor +) { + public record SkillListItem( + String slug, + String displayName, + String summary, + Object tags, + Object stats, + long createdAt, + long updatedAt, + LatestVersion latestVersion + ) { + public record LatestVersion( + String version, + long createdAt, + String changelog, + String license + ) {} + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java new file mode 100644 index 00000000..6f9fa154 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.compat.dto; + +import java.time.ZoneOffset; + +public record ClawHubSkillResponse( + SkillInfo skill, + VersionInfo latestVersion, + OwnerInfo owner, + ModerationInfo moderation +) { + public record SkillInfo( + String slug, + String displayName, + String summary, + Object tags, + Object stats, + long createdAt, + long updatedAt + ) {} + + public record VersionInfo( + String version, + long createdAt, + String changelog, + String license + ) {} + + public record OwnerInfo( + String handle, + String displayName, + String image + ) {} + + public record ModerationInfo( + boolean isSuspicious, + boolean isMalwareBlocked, + String verdict, + String[] reasonCodes, + Long updatedAt, + String engineVersion, + String summary + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java new file mode 100644 index 00000000..6c56e45d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubStarResponse( + boolean ok, + boolean starred, + boolean alreadyStarred +) { + public ClawHubStarResponse(boolean starred, boolean alreadyStarred) { + this(true, starred, alreadyStarred); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java new file mode 100644 index 00000000..50ce20fe --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubUnstarResponse( + boolean ok, + boolean unstarred, + boolean alreadyUnstarred +) { + public ClawHubUnstarResponse(boolean unstarred, boolean alreadyUnstarred) { + this(true, unstarred, alreadyUnstarred); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java index f2667ce1..99d8fa63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java @@ -1,7 +1,15 @@ package com.iflytek.skillhub.compat.dto; public record ClawHubWhoamiResponse( - String userId, - String displayName, - String email -) {} + User user +) { + public ClawHubWhoamiResponse(String handle, String displayName, String image) { + this(new User(handle, displayName, image)); + } + + public record User( + String handle, + String displayName, + String image + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthSessionBootstrapProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthSessionBootstrapProperties.java new file mode 100644 index 00000000..0bb4f6bf --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthSessionBootstrapProperties.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.session-bootstrap") +public class AuthSessionBootstrapProperties { + + /** + * Kept disabled in OSS by default. Private deployments can opt in explicitly. + */ + private boolean enabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DirectAuthProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DirectAuthProperties.java new file mode 100644 index 00000000..c1f88308 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DirectAuthProperties.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.direct") +public class DirectAuthProperties { + + /** + * Default disabled to keep OSS behavior unchanged. + */ + private boolean enabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 668a3b44..78fa6199 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -6,17 +6,31 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.time.Clock; + @Configuration public class DomainBeanConfig { + @Bean + public Clock utcClock() { + return Clock.systemUTC(); + } + @Bean public SkillMetadataParser skillMetadataParser() { return new SkillMetadataParser(); } @Bean - public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) { - return new SkillPackageValidator(skillMetadataParser); + public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser, + SkillPublishProperties skillPublishProperties) { + return new SkillPackageValidator( + skillMetadataParser, + skillPublishProperties.getMaxFileCount(), + skillPublishProperties.getMaxSingleFileSize(), + skillPublishProperties.getMaxPackageSize(), + skillPublishProperties.getAllowedFileExtensions() + ); } @Bean diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java new file mode 100644 index 00000000..3d922e90 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.ratelimit.download") +public class DownloadRateLimitProperties { + + private String anonymousCookieName = "skillhub_anon_dl"; + private Duration anonymousCookieMaxAge = Duration.ofDays(30); + private String anonymousCookieSecret = "change-me-in-production"; + + public String getAnonymousCookieName() { + return anonymousCookieName; + } + + public void setAnonymousCookieName(String anonymousCookieName) { + this.anonymousCookieName = anonymousCookieName; + } + + public Duration getAnonymousCookieMaxAge() { + return anonymousCookieMaxAge; + } + + public void setAnonymousCookieMaxAge(Duration anonymousCookieMaxAge) { + this.anonymousCookieMaxAge = anonymousCookieMaxAge; + } + + public String getAnonymousCookieSecret() { + return anonymousCookieSecret; + } + + public void setAnonymousCookieSecret(String anonymousCookieSecret) { + this.anonymousCookieSecret = anonymousCookieSecret; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java index e45f3c7f..214f7322 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java @@ -17,7 +17,7 @@ public OpenAPI skillhubOpenAPI() { .info(new Info() .title("SkillHub API") .description("Skills Registry Platform") - .version("0.1.0")) + .version("0.1.0-beta.7")) .servers(List.of( new Server().url("http://localhost:8080").description("Local development") )); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java new file mode 100644 index 00000000..98cb6de7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java @@ -0,0 +1,55 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; + +@Component +@ConfigurationProperties(prefix = "skillhub.publish") +public class SkillPublishProperties { + + private int maxFileCount = 100; + private long maxSingleFileSize = 10 * 1024 * 1024; // 10MB + private long maxPackageSize = 100 * 1024 * 1024; + private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( + ".md", ".txt", ".json", ".yaml", ".yml", ".html", ".css", ".csv", ".pdf", + ".toml", ".xml", ".ini", ".cfg", ".env", + ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", + ".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash", + ".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico" + )); + + public int getMaxFileCount() { + return maxFileCount; + } + + public void setMaxFileCount(int maxFileCount) { + this.maxFileCount = maxFileCount; + } + + public long getMaxSingleFileSize() { + return maxSingleFileSize; + } + + public void setMaxSingleFileSize(long maxSingleFileSize) { + this.maxSingleFileSize = maxSingleFileSize; + } + + public long getMaxPackageSize() { + return maxPackageSize; + } + + public void setMaxPackageSize(long maxPackageSize) { + this.maxPackageSize = maxPackageSize; + } + + public Set getAllowedFileExtensions() { + return allowedFileExtensions; + } + + public void setAllowedFileExtensions(Set allowedFileExtensions) { + this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java new file mode 100644 index 00000000..7e812f96 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -0,0 +1,71 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MergeInitiateRequest; +import com.iflytek.skillhub.dto.MergeInitiateResponse; +import com.iflytek.skillhub.dto.MergeVerifyRequest; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/account/merge") +public class AccountMergeController extends BaseApiController { + + private final AccountMergeService accountMergeService; + + public AccountMergeController(ApiResponseFactory responseFactory, + AccountMergeService accountMergeService) { + super(responseFactory); + this.accountMergeService = accountMergeService; + } + + @PostMapping("/initiate") + public ApiResponse initiate(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody MergeInitiateRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + var result = accountMergeService.initiate(principal.userId(), request.secondaryIdentifier()); + return ok("response.success.created", new MergeInitiateResponse( + result.mergeRequestId(), + result.secondaryUserId(), + result.verificationToken(), + result.expiresAt().toString() + )); + } + + @PostMapping("/verify") + public ApiResponse verify(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody MergeVerifyRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.verify( + principal.userId(), + request.mergeRequestId(), + request.verificationToken() + ); + return ok("response.success.updated", new MessageResponse("Account merge verified")); + } + + @PostMapping("/confirm") + public ApiResponse confirm(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody ConfirmMergeRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.confirm(principal.userId(), request.mergeRequestId()); + return ok("response.success.updated", new MessageResponse("Account merge completed")); + } + + public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index ec8dfca9..688f9fec 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -1,38 +1,160 @@ package com.iflytek.skillhub.controller; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuthMeResponse; +import com.iflytek.skillhub.dto.AuthMethodResponse; import com.iflytek.skillhub.dto.AuthProviderResponse; +import com.iflytek.skillhub.dto.DirectLoginRequest; +import com.iflytek.skillhub.dto.SessionBootstrapRequest; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.service.AuthMethodCatalog; +import com.iflytek.skillhub.service.DirectAuthService; +import com.iflytek.skillhub.service.SessionBootstrapService; +import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.security.AuthFailureThrottleService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + @RestController @RequestMapping("/api/v1/auth") public class AuthController extends BaseApiController { - public AuthController(ApiResponseFactory responseFactory) { + private final AuthMethodCatalog authMethodCatalog; + private final SessionBootstrapService sessionBootstrapService; + private final DirectAuthService directAuthService; + private final AuthFailureThrottleService authFailureThrottleService; + private final UserRoleBindingRepository userRoleBindingRepository; + private final PlatformSessionService platformSessionService; + private final UserAccountRepository userAccountRepository; + + public AuthController(ApiResponseFactory responseFactory, + AuthMethodCatalog authMethodCatalog, + SessionBootstrapService sessionBootstrapService, + DirectAuthService directAuthService, + AuthFailureThrottleService authFailureThrottleService, + UserRoleBindingRepository userRoleBindingRepository, + PlatformSessionService platformSessionService, + UserAccountRepository userAccountRepository) { super(responseFactory); + this.authMethodCatalog = authMethodCatalog; + this.sessionBootstrapService = sessionBootstrapService; + this.directAuthService = directAuthService; + this.authFailureThrottleService = authFailureThrottleService; + this.userRoleBindingRepository = userRoleBindingRepository; + this.platformSessionService = platformSessionService; + this.userAccountRepository = userAccountRepository; } @GetMapping("/me") public ApiResponse me(@AuthenticationPrincipal PlatformPrincipal principal, - Authentication authentication) { + Authentication authentication, + HttpServletRequest request) { if (principal == null || authentication == null || !authentication.isAuthenticated()) { throw new UnauthorizedException("error.auth.required"); } + UserAccount user = userAccountRepository.findById(principal.userId()).orElse(null); + if (user == null || user.getStatus() == UserStatus.DISABLED) { + request.getSession().invalidate(); + throw new UnauthorizedException("error.auth.required"); + } + Set freshRoles = PlatformRoleDefaults.withDefaultUserRole( + userRoleBindingRepository.findByUserId(principal.userId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet())); + if (!freshRoles.equals(principal.platformRoles())) { + principal = new PlatformPrincipal( + principal.userId(), principal.displayName(), principal.email(), + principal.avatarUrl(), principal.oauthProvider(), freshRoles); + platformSessionService.establishSession(principal, request, false); + } return ok("response.success.read", AuthMeResponse.from(principal)); } @GetMapping("/providers") - public ApiResponse> providers() { - var github = new AuthProviderResponse("github", "GitHub", "/oauth2/authorization/github"); - return ok("response.success.read", List.of(github)); + public ApiResponse> providers( + @RequestParam(name = "returnTo", required = false) String returnTo) { + return ok("response.success.read", authMethodCatalog.listOAuthProviders(returnTo)); + } + + @GetMapping("/methods") + public ApiResponse> methods( + @RequestParam(name = "returnTo", required = false) String returnTo) { + return ok("response.success.read", authMethodCatalog.listMethods(returnTo)); + } + + @PostMapping("/session/bootstrap") + @RateLimit(category = "auth-session-bootstrap", authenticated = 30, anonymous = 15, windowSeconds = 60) + public ApiResponse bootstrapSession(@Valid @RequestBody SessionBootstrapRequest request, + HttpServletRequest httpRequest) { + return ok( + "response.success.read", + AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest)) + ); } + + @PostMapping("/direct/login") + @RateLimit(category = "auth-direct-login", authenticated = 20, anonymous = 10, windowSeconds = 60) + public ApiResponse directLogin(@Valid @RequestBody DirectLoginRequest request, + HttpServletRequest httpRequest) { + String category = "direct:" + request.provider(); + String clientIp = resolveClientIp(httpRequest); + authFailureThrottleService.assertAllowed(category, request.username(), clientIp); + PlatformPrincipal principal; + try { + principal = directAuthService.authenticate( + request.provider(), + request.username(), + request.password(), + httpRequest + ); + } catch (AuthFlowException ex) { + if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) { + authFailureThrottleService.recordFailure(category, request.username(), clientIp); + } + throw ex; + } + authFailureThrottleService.resetIdentifier(category, request.username()); + return ok( + "response.success.read", + AuthMeResponse.from(principal) + ); + } + + private String resolveClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java deleted file mode 100644 index 5363eab7..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.iflytek.skillhub.controller; - -import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import com.iflytek.skillhub.domain.skill.validation.ValidationResult; -import com.iflytek.skillhub.dto.ApiResponse; -import com.iflytek.skillhub.dto.ApiResponseFactory; -import com.iflytek.skillhub.dto.CliWhoamiResponse; -import com.iflytek.skillhub.dto.SkillCheckResponse; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import com.iflytek.skillhub.exception.UnauthorizedException; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.util.List; - -@RestController -@RequestMapping("/api/v1/cli") -public class CliController extends BaseApiController { - - private final SkillPackageValidator skillPackageValidator; - private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; - - public CliController(ApiResponseFactory responseFactory, - SkillPackageValidator skillPackageValidator, - SkillPackageArchiveExtractor skillPackageArchiveExtractor) { - super(responseFactory); - this.skillPackageValidator = skillPackageValidator; - this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; - } - - @GetMapping("/whoami") - public ApiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { - throw new UnauthorizedException("error.auth.required"); - } - - return ok("response.success.read", CliWhoamiResponse.from(principal)); - } - - @PostMapping("/check") - public ApiResponse check(@RequestParam("file") MultipartFile file) throws IOException { - List entries; - try { - entries = skillPackageArchiveExtractor.extract(file); - } catch (IllegalArgumentException e) { - SkillCheckResponse response = new SkillCheckResponse( - false, - List.of(e.getMessage()), - 0, - 0L - ); - return ok("response.success.validated", response); - } - ValidationResult result = skillPackageValidator.validate(entries); - - SkillCheckResponse response = new SkillCheckResponse( - result.passed(), - result.errors(), - entries.size(), - entries.stream().mapToLong(PackageEntry::size).sum() - ); - - return ok("response.success.validated", response); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java index 2b61b7c9..02f7d311 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/cli/auth/device") +@RequestMapping("/api/v1/auth/device") public class DeviceAuthController extends BaseApiController { private final DeviceAuthService deviceAuthService; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index 13eca9da..aec99cf1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -2,9 +2,12 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.MessageResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -16,18 +19,33 @@ public class DeviceAuthWebController extends BaseApiController { private final DeviceAuthService deviceAuthService; + private final AuditLogService auditLogService; - public DeviceAuthWebController(ApiResponseFactory responseFactory, DeviceAuthService deviceAuthService) { + public DeviceAuthWebController(ApiResponseFactory responseFactory, + DeviceAuthService deviceAuthService, + AuditLogService auditLogService) { super(responseFactory); this.deviceAuthService = deviceAuthService; + this.auditLogService = auditLogService; } @PostMapping("/authorize") public ApiResponse authorizeDevice( @RequestBody AuthorizeRequest request, - @AuthenticationPrincipal PlatformPrincipal principal + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest ) { deviceAuthService.authorizeDeviceCode(request.userCode(), principal.userId()); + auditLogService.record( + principal.userId(), + "DEVICE_AUTHORIZE", + "DEVICE_CODE", + null, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"userCode\":\"" + request.userCode() + "\"}" + ); return ok("response.success.updated", new MessageResponse("Device authorized successfully")); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java new file mode 100644 index 00000000..daea30e0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -0,0 +1,105 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AuthMeResponse; +import com.iflytek.skillhub.dto.ChangePasswordRequest; +import com.iflytek.skillhub.dto.LocalLoginRequest; +import com.iflytek.skillhub.dto.LocalRegisterRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.security.AuthFailureThrottleService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth/local") +public class LocalAuthController extends BaseApiController { + + private final LocalAuthService localAuthService; + private final SkillHubMetrics skillHubMetrics; + private final PlatformSessionService platformSessionService; + private final AuthFailureThrottleService authFailureThrottleService; + + public LocalAuthController(ApiResponseFactory responseFactory, + LocalAuthService localAuthService, + SkillHubMetrics skillHubMetrics, + PlatformSessionService platformSessionService, + AuthFailureThrottleService authFailureThrottleService) { + super(responseFactory); + this.localAuthService = localAuthService; + this.skillHubMetrics = skillHubMetrics; + this.platformSessionService = platformSessionService; + this.authFailureThrottleService = authFailureThrottleService; + } + + @PostMapping("/register") + @RateLimit(category = "auth-register", authenticated = 10, anonymous = 5, windowSeconds = 300) + public ApiResponse register(@Valid @RequestBody LocalRegisterRequest request, + HttpServletRequest httpRequest) { + PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); + skillHubMetrics.incrementUserRegister(); + platformSessionService.establishSession(principal, httpRequest); + return ok("response.success.created", AuthMeResponse.from(principal)); + } + + @PostMapping("/login") + @RateLimit(category = "auth-local-login", authenticated = 20, anonymous = 10, windowSeconds = 60) + public ApiResponse login(@Valid @RequestBody LocalLoginRequest request, + HttpServletRequest httpRequest) { + authFailureThrottleService.assertAllowed("local", request.username(), resolveClientIp(httpRequest)); + PlatformPrincipal principal; + try { + principal = localAuthService.login(request.username(), request.password()); + } catch (AuthFlowException ex) { + if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) { + authFailureThrottleService.recordFailure("local", request.username(), resolveClientIp(httpRequest)); + } + skillHubMetrics.recordLocalLogin(false); + throw ex; + } catch (RuntimeException ex) { + skillHubMetrics.recordLocalLogin(false); + throw ex; + } + authFailureThrottleService.resetIdentifier("local", request.username()); + skillHubMetrics.recordLocalLogin(true); + platformSessionService.establishSession(principal, httpRequest); + return ok("response.success.read", AuthMeResponse.from(principal)); + } + + @PostMapping("/change-password") + @RateLimit(category = "auth-change-password", authenticated = 5, anonymous = 20, windowSeconds = 300) + public ApiResponse changePassword(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody ChangePasswordRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword()); + return ok("response.success.updated", null); + } + + private String resolveClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index cb2af098..673f3544 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -1,17 +1,22 @@ package com.iflytek.skillhub.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TokenCreateRequest; import com.iflytek.skillhub.dto.TokenCreateResponse; +import com.iflytek.skillhub.dto.TokenExpirationUpdateRequest; import com.iflytek.skillhub.dto.TokenSummaryResponse; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.util.List; @RestController @@ -19,43 +24,55 @@ public class TokenController extends BaseApiController { private final ApiTokenService apiTokenService; + private final ObjectMapper objectMapper; - public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory) { + public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory, ObjectMapper objectMapper) { super(responseFactory); this.apiTokenService = apiTokenService; + this.objectMapper = objectMapper; } @PostMapping public ApiResponse create( @AuthenticationPrincipal PlatformPrincipal principal, @Valid @RequestBody TokenCreateRequest request) { - String scopeJson = request.scopes() == null || request.scopes().isEmpty() - ? "[\"skill:read\",\"skill:publish\"]" - : request.scopes().toString(); + String scopeJson; + if (request.scopes() == null || request.scopes().isEmpty()) { + scopeJson = "[\"skill:read\",\"skill:publish\"]"; + } else { + try { + scopeJson = objectMapper.writeValueAsString(request.scopes()); + } catch (JsonProcessingException e) { + scopeJson = "[\"skill:read\",\"skill:publish\"]"; + } + } - var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson); + var result = apiTokenService.rotateToken(principal.userId(), request.name(), scopeJson, request.expiresAt()); return ok("response.success.created", new TokenCreateResponse( result.rawToken(), result.entity().getId(), result.entity().getName(), result.entity().getTokenPrefix(), - result.entity().getCreatedAt().toString(), - result.entity().getExpiresAt() != null ? result.entity().getExpiresAt().toString() : "" + formatInstant(result.entity().getCreatedAt()), + formatInstant(result.entity().getExpiresAt()) )); } @GetMapping - public ApiResponse> list(@AuthenticationPrincipal PlatformPrincipal principal) { - var tokens = apiTokenService.listActiveTokens(principal.userId()); - var result = tokens.stream().map(t -> new TokenSummaryResponse( + public ApiResponse> list( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + var tokens = apiTokenService.listActiveTokens(principal.userId(), page, size); + var result = tokens.map(t -> new TokenSummaryResponse( t.getId(), t.getName(), t.getTokenPrefix(), - t.getCreatedAt().toString(), - t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", - t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" - )).toList(); - return ok("response.success.read", result); + formatInstant(t.getCreatedAt()), + formatInstant(t.getExpiresAt()), + formatInstant(t.getLastUsedAt()) + )); + return ok("response.success.read", PageResponse.from(result)); } @DeleteMapping("/{id}") @@ -65,4 +82,24 @@ public ResponseEntity revoke( apiTokenService.revokeToken(id, principal.userId()); return ResponseEntity.noContent().build(); } + + @PutMapping("/{id}/expiration") + public ApiResponse updateExpiration( + @AuthenticationPrincipal PlatformPrincipal principal, + @PathVariable Long id, + @RequestBody TokenExpirationUpdateRequest request) { + var token = apiTokenService.updateExpiration(id, principal.userId(), request.expiresAt()); + return ok("response.success.updated", new TokenSummaryResponse( + token.getId(), + token.getName(), + token.getTokenPrefix(), + formatInstant(token.getCreatedAt()), + formatInstant(token.getExpiresAt()), + formatInstant(token.getLastUsedAt()) + )); + } + + private String formatInstant(Instant value) { + return value == null ? "" : value.toString(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java new file mode 100644 index 00000000..708cfdb8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.AdminSkillActionRequest; +import com.iflytek.skillhub.dto.AdminSkillMutationResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/skills") +public class AdminSkillController extends BaseApiController { + + private final SkillGovernanceService skillGovernanceService; + + public AdminSkillController(ApiResponseFactory responseFactory, + SkillGovernanceService skillGovernanceService) { + super(responseFactory); + this.skillGovernanceService = skillGovernanceService; + } + + @PostMapping("/{skillId}/hide") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse hideSkill(@PathVariable Long skillId, + @RequestBody(required = false) AdminSkillActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var skill = skillGovernanceService.hideSkill( + skillId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "HIDE", skill.getStatus().name())); + } + + @PostMapping("/{skillId}/unhide") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse unhideSkill(@PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var skill = skillGovernanceService.unhideSkill( + skillId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "UNHIDE", skill.getStatus().name())); + } + + @PostMapping("/versions/{versionId}/yank") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse yankVersion(@PathVariable Long versionId, + @RequestBody(required = false) AdminSkillActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var version = skillGovernanceService.yankVersion( + versionId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + return ok("response.success.updated", new AdminSkillMutationResponse(version.getSkillId(), versionId, "YANK", version.getStatus().name())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java new file mode 100644 index 00000000..232ed8c0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.report.SkillReportDisposition; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.dto.AdminSkillReportActionRequest; +import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillReportMutationResponse; +import com.iflytek.skillhub.service.AdminSkillReportAppService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/skill-reports") +public class AdminSkillReportController extends BaseApiController { + + private final AdminSkillReportAppService adminSkillReportAppService; + private final SkillReportService skillReportService; + + public AdminSkillReportController(AdminSkillReportAppService adminSkillReportAppService, + SkillReportService skillReportService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.adminSkillReportAppService = adminSkillReportAppService; + this.skillReportService = skillReportService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse> listReports( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success", adminSkillReportAppService.listReports(status, page, size)); + } + + @PostMapping("/{reportId}/resolve") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse resolveReport(@PathVariable Long reportId, + @RequestBody(required = false) AdminSkillReportActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + SkillReportDisposition disposition = request != null && request.disposition() != null + ? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase()) + : SkillReportDisposition.RESOLVE_ONLY; + if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE && !principal.platformRoles().contains("SUPER_ADMIN")) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + var report = skillReportService.resolveReport( + reportId, + principal.userId(), + disposition, + request != null ? request.comment() : null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); + } + + @PostMapping("/{reportId}/dismiss") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse dismissReport(@PathVariable Long reportId, + @RequestBody(required = false) AdminSkillReportActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var report = skillReportService.dismissReport( + reportId, + principal.userId(), + request != null ? request.comment() : null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 21fa7405..2616ea7e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -9,6 +9,8 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.time.Instant; + @RestController @RequestMapping("/api/v1/admin/audit-logs") public class AuditLogController extends BaseApiController { @@ -27,7 +29,23 @@ public ApiResponse> listAuditLogs( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String userId, - @RequestParam(required = false) String action) { - return ok("response.success.read", adminAuditLogAppService.listAuditLogs(page, size, userId, action)); + @RequestParam(required = false) String action, + @RequestParam(required = false) String requestId, + @RequestParam(required = false) String ipAddress, + @RequestParam(required = false) String resourceType, + @RequestParam(required = false) String resourceId, + @RequestParam(required = false) Instant startTime, + @RequestParam(required = false) Instant endTime) { + return ok("response.success.read", adminAuditLogAppService.listAuditLogs( + page, + size, + userId, + action, + requestId, + ipAddress, + resourceType, + resourceId, + startTime, + endTime)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index bffd32c5..850ecc26 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -54,4 +54,22 @@ public ApiResponse updateUserStatus( @Valid @RequestBody AdminUserStatusUpdateRequest request) { return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status())); } + + @PostMapping("/{userId}/approve") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse approveUser(@PathVariable String userId) { + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); + } + + @PostMapping("/{userId}/disable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse disableUser(@PathVariable String userId) { + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED")); + } + + @PostMapping("/{userId}/enable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse enableUser(@PathVariable String userId) { + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java deleted file mode 100644 index 953ee4f8..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.iflytek.skillhub.controller.cli; - -import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.dto.ApiResponse; -import com.iflytek.skillhub.dto.ApiResponseFactory; -import com.iflytek.skillhub.dto.PublishResponse; -import com.iflytek.skillhub.ratelimit.RateLimit; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.util.List; - -@RestController -@RequestMapping("/api/v1/cli") -public class CliPublishController extends BaseApiController { - - private final SkillPublishService skillPublishService; - private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; - - public CliPublishController(SkillPublishService skillPublishService, - SkillPackageArchiveExtractor skillPackageArchiveExtractor, - ApiResponseFactory responseFactory) { - super(responseFactory); - this.skillPublishService = skillPublishService; - this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; - } - - @PostMapping("/publish") - @RateLimit(category = "publish", authenticated = 10, anonymous = 0) - public ApiResponse publish( - @RequestParam("file") MultipartFile file, - @RequestParam("namespace") String namespace, - @RequestParam("visibility") String visibility, - @RequestAttribute("userId") String userId) throws IOException { - - SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - - List entries; - try { - entries = skillPackageArchiveExtractor.extract(file); - } catch (IllegalArgumentException e) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage()); - } - - SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( - namespace, - entries, - userId, - skillVisibility - ); - - PublishResponse response = new PublishResponse( - publishResult.skillId(), - namespace, - publishResult.slug(), - publishResult.version().getVersion(), - publishResult.version().getStatus().name(), - publishResult.version().getFileCount(), - publishResult.version().getTotalSize() - ); - - return ok("response.success.published", response); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java new file mode 100644 index 00000000..e14e4ea7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java @@ -0,0 +1,115 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceNotificationResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkbenchAppService; +import java.util.Map; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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; + +@RestController +@RequestMapping({"/api/v1/governance", "/api/web/governance"}) +public class GovernanceController extends BaseApiController { + + private final GovernanceWorkbenchAppService governanceWorkbenchAppService; + private final RbacService rbacService; + private final GovernanceNotificationService governanceNotificationService; + + public GovernanceController(GovernanceWorkbenchAppService governanceWorkbenchAppService, + RbacService rbacService, + GovernanceNotificationService governanceNotificationService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.governanceWorkbenchAppService = governanceWorkbenchAppService; + this.rbacService = rbacService; + this.governanceNotificationService = governanceNotificationService; + } + + @GetMapping("/summary") + public ApiResponse summary( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok( + "response.success.read", + governanceWorkbenchAppService.getSummary(userId, userNsRoles != null ? userNsRoles : Map.of(), roles(userId)) + ); + } + + @GetMapping("/inbox") + public ApiResponse> inbox( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok( + "response.success.read", + governanceWorkbenchAppService.listInbox( + userId, + userNsRoles != null ? userNsRoles : Map.of(), + roles(userId), + type, + page, + size + ) + ); + } + + @GetMapping("/activity") + public ApiResponse> activity( + @RequestAttribute("userId") String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success.read", governanceWorkbenchAppService.listActivity(roles(userId), page, size)); + } + + @GetMapping("/notifications") + public ApiResponse> notifications( + @RequestAttribute("userId") String userId) { + return ok( + "response.success.read", + governanceNotificationService.listNotifications(userId).stream().map(this::toNotificationResponse).toList() + ); + } + + @PostMapping("/notifications/{id}/read") + public ApiResponse markNotificationRead( + @PathVariable Long id, + @RequestAttribute("userId") String userId) { + return ok("response.success.updated", toNotificationResponse(governanceNotificationService.markRead(id, userId))); + } + + private Set roles(String userId) { + return rbacService.getUserRoleCodes(userId); + } + + private GovernanceNotificationResponse toNotificationResponse(UserNotification notification) { + return new GovernanceNotificationResponse( + notification.getId(), + notification.getCategory(), + notification.getEntityType(), + notification.getEntityId(), + notification.getTitle(), + notification.getBodyJson(), + notification.getStatus().name(), + notification.getCreatedAt() != null ? notification.getCreatedAt().toString() : null, + notification.getReadAt() != null ? notification.getReadAt().toString() : null + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index 9b4c8f75..dd94be4b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -4,18 +4,18 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.service.MySkillAppService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController -@RequestMapping("/api/v1/me") +@RequestMapping({"/api/v1/me", "/api/web/me"}) public class MeController extends BaseApiController { private final MySkillAppService mySkillAppService; @@ -26,12 +26,26 @@ public MeController(MySkillAppService mySkillAppService, ApiResponseFactory resp } @GetMapping("/skills") - public ApiResponse> listMySkills( + public ApiResponse> listMySkills( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMySkills(principal.userId(), page, size)); + } + + @GetMapping("/stars") + public ApiResponse> listMyStars( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "12") int size, @AuthenticationPrincipal PlatformPrincipal principal) { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - return ok("response.success.read", mySkillAppService.listMySkills(principal.userId())); + return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index d3995006..52f14913 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -4,47 +4,90 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.*; import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.exception.ForbiddenException; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + @RestController -@RequestMapping("/api/v1/namespaces") +@RequestMapping({"/api/v1", "/api/web"}) public class NamespaceController extends BaseApiController { private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceRepository namespaceRepository; + private final NamespaceGovernanceService namespaceGovernanceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final NamespaceMemberCandidateService namespaceMemberCandidateService; public NamespaceController(NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, NamespaceRepository namespaceRepository, + NamespaceGovernanceService namespaceGovernanceService, + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceMemberCandidateService namespaceMemberCandidateService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceRepository = namespaceRepository; + this.namespaceGovernanceService = namespaceGovernanceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceMemberCandidateService = namespaceMemberCandidateService; } - @GetMapping + @GetMapping("/namespaces") public ApiResponse> listNamespaces(Pageable pageable) { Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); PageResponse response = PageResponse.from(namespaces.map(NamespaceResponse::from)); return ok("response.success.read", response); } - @GetMapping("/{slug}") - public ApiResponse getNamespace(@PathVariable String slug) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); + @GetMapping("/me/namespaces") + public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + return ok("response.success.read", List.of()); + } + + List response = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .map(namespace -> MyNamespaceResponse.from(namespace, namespaceRoles.get(namespace.getId()), namespaceAccessPolicy)) + .toList(); + + return ok("response.success.read", response); + } + + @GetMapping("/namespaces/{slug}") + public ApiResponse getNamespace(@PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Namespace namespace = namespaceService.getNamespaceBySlugForRead(slug, userId, userNsRoles != null ? userNsRoles : Map.of()); return ok("response.success.read", NamespaceResponse.from(namespace)); } - @PostMapping + @PostMapping("/namespaces") public ApiResponse createNamespace( @Valid @RequestBody NamespaceRequest request, @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + if (!canCreateNamespace(principal)) { + throw new ForbiddenException("error.namespace.create.platformAdminRequired"); + } + Namespace namespace = namespaceService.createNamespace( request.slug(), request.displayName(), @@ -54,7 +97,12 @@ public ApiResponse createNamespace( return ok("response.success.created", NamespaceResponse.from(namespace)); } - @PutMapping("/{slug}") + private boolean canCreateNamespace(PlatformPrincipal principal) { + return principal.platformRoles().contains("SKILL_ADMIN") + || principal.platformRoles().contains("SUPER_ADMIN"); + } + + @PutMapping("/namespaces/{slug}") public ApiResponse updateNamespace( @PathVariable String slug, @RequestBody NamespaceRequest request, @@ -70,15 +118,87 @@ public ApiResponse updateNamespace( return ok("response.success.updated", NamespaceResponse.from(updated)); } - @GetMapping("/{slug}/members") - public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable) { + @PostMapping("/namespaces/{slug}/freeze") + public ApiResponse freezeNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.freezeNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/unfreeze") + public ApiResponse unfreezeNamespace(@PathVariable String slug, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.unfreezeNamespace( + slug, + userId, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/archive") + public ApiResponse archiveNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.archiveNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/restore") + public ApiResponse restoreNamespace(@PathVariable String slug, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.restoreNamespace( + slug, + userId, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @GetMapping("/namespaces/{slug}/members") + public ApiResponse> listMembers(@PathVariable String slug, + Pageable pageable, + @RequestAttribute("userId") String userId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); + namespaceService.assertMember(namespace.getId(), userId); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); PageResponse response = PageResponse.from(members.map(MemberResponse::from)); return ok("response.success.read", response); } - @PostMapping("/{slug}/members") + @GetMapping("/namespaces/{slug}/member-candidates") + public ApiResponse> searchMemberCandidates( + @PathVariable String slug, + @RequestParam String search, + @RequestParam(defaultValue = "10") int size, + @RequestAttribute("userId") String userId) { + return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size)); + } + + @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, @Valid @RequestBody MemberRequest request, @@ -93,7 +213,7 @@ public ApiResponse addMember( return ok("response.success.created", MemberResponse.from(member)); } - @DeleteMapping("/{slug}/members/{userId}") + @DeleteMapping("/namespaces/{slug}/members/{userId}") public ApiResponse removeMember( @PathVariable String slug, @PathVariable("userId") String memberUserId, @@ -103,7 +223,7 @@ public ApiResponse removeMember( return ok("response.success.deleted", new MessageResponse("Member removed successfully")); } - @PutMapping("/{slug}/members/{userId}/role") + @PutMapping("/namespaces/{slug}/members/{userId}/role") public ApiResponse updateMemberRole( @PathVariable String slug, @PathVariable String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 66d276d0..5617f21c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -2,31 +2,46 @@ import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +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.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; -import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; 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.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.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.PromotionActionRequest; +import com.iflytek.skillhub.dto.PromotionRequestDto; +import com.iflytek.skillhub.dto.PromotionResponseDto; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.Set; @RestController -@RequestMapping("/api/v1/promotions") +@RequestMapping({"/api/v1/promotions", "/api/web/promotions"}) public class PromotionController extends BaseApiController { private final PromotionService promotionService; @@ -36,7 +51,7 @@ public class PromotionController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; - private final ReviewPermissionChecker permissionChecker; + private final AuditLogService auditLogService; public PromotionController(PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, @@ -45,7 +60,7 @@ public PromotionController(PromotionService promotionService, NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, - ReviewPermissionChecker permissionChecker, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.promotionService = promotionService; @@ -55,50 +70,74 @@ public PromotionController(PromotionService promotionService, this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; - this.permissionChecker = permissionChecker; + this.auditLogService = auditLogService; } @PostMapping - public ApiResponse submitPromotion( - @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse submitPromotion(@RequestBody PromotionRequestDto request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { PromotionRequest promotion = promotionService.submitPromotion( - request.sourceSkillId(), request.sourceVersionId(), - request.targetNamespaceId(), userId, - userNsRoles != null ? userNsRoles : Map.of()); - return ok("response.success.create", toResponse(promotion)); + request.sourceSkillId(), + request.sourceVersionId(), + request.targetNamespaceId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); + recordAudit( + "PROMOTION_SUBMIT", + userId, + promotion.getId(), + httpRequest, + "{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}" + ); + return ok("response.success.created", toResponse(promotion)); } @PostMapping("/{id}/approve") - public ApiResponse approvePromotion( - @PathVariable Long id, - @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + public ApiResponse approvePromotion(@PathVariable Long id, + @RequestBody(required = false) PromotionActionRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles); + PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } @PostMapping("/{id}/reject") - public ApiResponse rejectPromotion( - @PathVariable Long id, - @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + public ApiResponse rejectPromotion(@PathVariable Long id, + @RequestBody(required = false) PromotionActionRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles); + PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } + @GetMapping + public ApiResponse> listPromotions(@RequestParam(defaultValue = "PENDING") String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { + Set platformRoles = rbacService.getUserRoleCodes(userId); + if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { + throw new DomainForbiddenException("promotion.no_permission"); + } + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); + } + @GetMapping("/pending") - public ApiResponse> listPendingPromotions( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId) { + public ApiResponse> listPendingPromotions(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canListPendingPromotions(platformRoles)) { + if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { throw new DomainForbiddenException("promotion.no_permission"); } Page requests = promotionRequestRepository.findByStatus( @@ -107,47 +146,73 @@ public ApiResponse> listPendingPromotions( } @GetMapping("/{id}") - public ApiResponse getPromotionDetail( - @PathVariable Long id, - @RequestAttribute("userId") String userId) { - PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow(); - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canReadPromotion(promotion, userId, platformRoles)) { + public ApiResponse getPromotionDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId) { + PromotionRequest promotion = promotionRequestRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id)); + if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("promotion.no_permission"); } return ok("response.success.read", toResponse(promotion)); } - private PromotionResponseDto toResponse(PromotionRequest req) { - Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()).orElseThrow(); - SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()).orElseThrow(); - Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()).orElseThrow(); - Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()).orElseThrow(); - - String submittedByName = userAccountRepository.findById(req.getSubmittedBy()) - .map(UserAccount::getDisplayName).orElse(null); + private PromotionResponseDto toResponse(PromotionRequest request) { + Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); + SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId())); + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId())); - String reviewedByName = req.getReviewedBy() != null - ? userAccountRepository.findById(req.getReviewedBy()) - .map(UserAccount::getDisplayName).orElse(null) + String submittedByName = userAccountRepository.findById(request.getSubmittedBy()) + .map(UserAccount::getDisplayName) + .orElse(null); + String reviewedByName = request.getReviewedBy() != null + ? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) : null; return new PromotionResponseDto( - req.getId(), - req.getSourceSkillId(), - sourceNs.getSlug(), + request.getId(), + request.getSourceSkillId(), + sourceNamespace.getSlug(), sourceSkill.getSlug(), sourceVersion.getVersion(), - targetNs.getSlug(), - req.getTargetSkillId(), - req.getStatus().name(), - req.getSubmittedBy(), + targetNamespace.getSlug(), + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), submittedByName, - req.getReviewedBy(), + request.getReviewedBy(), reviewedByName, - req.getReviewComment(), - req.getSubmittedAt(), - req.getReviewedAt() + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() ); } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "PROMOTION_REQUEST", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } 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 1b92da5b..54a9ca7d 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 @@ -2,31 +2,47 @@ import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +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.ReviewService; -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.shared.exception.DomainForbiddenException; +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.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewActionRequest; +import com.iflytek.skillhub.dto.ReviewTaskRequest; +import com.iflytek.skillhub.dto.ReviewTaskResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.Set; @RestController -@RequestMapping("/api/v1/reviews") +@RequestMapping({"/api/v1/reviews", "/api/web/reviews"}) public class ReviewController extends BaseApiController { private final ReviewService reviewService; @@ -36,7 +52,7 @@ public class ReviewController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; - private final ReviewPermissionChecker permissionChecker; + private final AuditLogService auditLogService; public ReviewController(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, @@ -45,7 +61,7 @@ public ReviewController(ReviewService reviewService, NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, - ReviewPermissionChecker permissionChecker, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.reviewService = reviewService; @@ -55,71 +71,125 @@ public ReviewController(ReviewService reviewService, this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; - this.permissionChecker = permissionChecker; + this.auditLogService = auditLogService; } @PostMapping - public ApiResponse submitReview( - @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse submitReview(@RequestBody ReviewTaskRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { ReviewTask task = reviewService.submitReview( request.skillVersionId(), userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) ); - return ok("response.success.create", toResponse(task)); + recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}"); + return ok("response.success.created", toResponse(task)); } @PostMapping("/{id}/approve") - public ApiResponse approveReview( - @PathVariable Long id, - @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse approveReview(@PathVariable Long id, + @RequestBody(required = false) ReviewActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - ReviewTask task = reviewService.approveReview(id, userId, comment, - userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + ReviewTask task = reviewService.approveReview( + id, + userId, + comment, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); + recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/reject") - public ApiResponse rejectReview( - @PathVariable Long id, - @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse rejectReview(@PathVariable Long id, + @RequestBody(required = false) ReviewActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - ReviewTask task = reviewService.rejectReview(id, userId, comment, - userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + ReviewTask task = reviewService.rejectReview( + id, + userId, + comment, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); + recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/withdraw") - public ApiResponse withdrawReview( - @PathVariable Long id, - @RequestAttribute("userId") String userId) { - ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); + public ApiResponse withdrawReview(@PathVariable Long id, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + ReviewTask task = reviewTaskRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); reviewService.withdrawReview(task.getSkillVersionId(), userId); + recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}"); return ok("response.success.updated", null); } + @GetMapping + public ApiResponse> listReviews(@RequestParam String status, + @RequestParam(required = false) Long namespaceId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + + Page tasks; + if (namespaceId != null) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace( + probe, + userId, + namespace.getType(), + namespaceRoles, + rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); + } else { + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + } + + java.util.List visibleItems = tasks.getContent().stream() + .filter(task -> canViewReview(task, userId, namespaceRoles)) + .map(this::toResponse) + .toList(); + + return ok( + "response.success.read", + PageResponse.from(new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size())) + ); + } + @GetMapping("/pending") - public ApiResponse> listPendingReviews( - @RequestParam Long namespaceId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - Namespace namespace = namespaceRepository.findById(namespaceId).orElseThrow(); - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canManageNamespaceReviews( - namespaceId, + public ApiResponse> listPendingReviews(@RequestParam Long namespaceId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace( + probe, + userId, namespace.getType(), userNsRoles != null ? userNsRoles : Map.of(), - platformRoles)) { + rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("review.no_permission"); } @@ -129,53 +199,54 @@ public ApiResponse> listPendingReviews( } @GetMapping("/my-submissions") - public ApiResponse> listMySubmissions( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId) { + public ApiResponse> listMySubmissions(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); } @GetMapping("/{id}") - public ApiResponse getReviewDetail( - @PathVariable Long id, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); - Namespace namespace = namespaceRepository.findById(task.getNamespaceId()).orElseThrow(); - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canReadReview( + public ApiResponse getReviewDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTask task = reviewTaskRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + if (!reviewService.canViewReview( task, userId, namespace.getType(), userNsRoles != null ? userNsRoles : Map.of(), - platformRoles)) { + rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("review.no_permission"); } return ok("response.success.read", toResponse(task)); } private ReviewTaskResponse toResponse(ReviewTask task) { - SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow(); - Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow(); + SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) - .map(UserAccount::getDisplayName).orElse(null); - + .map(UserAccount::getDisplayName) + .orElse(null); String reviewedByName = task.getReviewedBy() != null - ? userAccountRepository.findById(task.getReviewedBy()) - .map(UserAccount::getDisplayName).orElse(null) + ? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) : null; return new ReviewTaskResponse( task.getId(), task.getSkillVersionId(), - ns.getSlug(), + namespace.getSlug(), skill.getSlug(), - sv.getVersion(), + skillVersion.getVersion(), task.getStatus().name(), task.getSubmittedBy(), submittedByName, @@ -186,4 +257,40 @@ private ReviewTaskResponse toResponse(ReviewTask task) { task.getReviewedAt() ); } + + private boolean canViewReview(ReviewTask task, String userId, Map namespaceRoles) { + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + return reviewService.canViewReview( + task, + userId, + namespace.getType(), + namespaceRoles, + rbacService.getUserRoleCodes(userId) + ); + } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "REVIEW_TASK", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 2fba90df..78f5127a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; @@ -12,36 +13,44 @@ import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; +import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillVersionDetailResponse; import com.iflytek.skillhub.dto.SkillVersionResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletRequest; import java.io.InputStream; +import java.net.URI; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @RestController -@RequestMapping("/api/v1/skills") +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillController extends BaseApiController { private final SkillQueryService skillQueryService; private final SkillDownloadService skillDownloadService; + private final SkillHubMetrics metrics; public SkillController( SkillQueryService skillQueryService, SkillDownloadService skillDownloadService, + SkillHubMetrics metrics, ApiResponseFactory responseFactory) { super(responseFactory); this.skillQueryService = skillQueryService; this.skillDownloadService = skillDownloadService; + this.metrics = metrics; } @GetMapping("/{namespace}/{slug}") @@ -63,8 +72,18 @@ public ApiResponse getSkillDetail( detail.status(), detail.downloadCount(), detail.starCount(), - detail.latestVersion(), - namespace + detail.ratingAvg(), + detail.ratingCount(), + detail.hidden(), + namespace, + detail.canManageLifecycle(), + detail.canSubmitPromotion(), + detail.canInteract(), + detail.canReport(), + toLifecycleVersion(detail.headlineVersion()), + toLifecycleVersion(detail.publishedVersion()), + toLifecycleVersion(detail.ownerPreviewVersion()), + detail.resolutionMode() ); return ok("response.success.read", response); @@ -75,10 +94,16 @@ public ApiResponse> listVersions( @PathVariable String namespace, @PathVariable String slug, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { + @RequestParam(defaultValue = "20") int size, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { Page versions = skillQueryService.listVersions( - namespace, slug, PageRequest.of(page, size)); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + PageRequest.of(page, size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), @@ -87,7 +112,8 @@ public ApiResponse> listVersions( v.getChangelog(), v.getFileCount(), v.getTotalSize(), - v.getPublishedAt() + v.getPublishedAt(), + skillQueryService.isDownloadAvailable(v) ))); return ok("response.success.read", response); @@ -266,17 +292,14 @@ public ApiResponse resolveVersion( public ResponseEntity downloadLatest( @PathVariable String namespace, @PathVariable String slug, + HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + return buildDownloadResponse(request, result); } @GetMapping("/{namespace}/{slug}/versions/{version}/download") @@ -285,17 +308,14 @@ public ResponseEntity downloadVersion( @PathVariable String namespace, @PathVariable String slug, @PathVariable String version, + HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + return buildDownloadResponse(request, result); } @GetMapping("/{namespace}/{slug}/tags/{tagName}/download") @@ -304,16 +324,64 @@ public ResponseEntity downloadByTag( @PathVariable String namespace, @PathVariable String slug, @PathVariable String tagName, + HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + return buildDownloadResponse(request, result); + } + + private ResponseEntity buildDownloadResponse(HttpServletRequest request, SkillDownloadService.DownloadResult result) { + if (shouldRedirectToPresignedUrl(request, result.presignedUrl())) { + metrics.recordDownloadDelivery("redirect", result.fallbackBundle()); + if (result.fallbackBundle()) { + metrics.incrementBundleMissingFallback(); + } + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, result.presignedUrl()) + .build(); + } + + metrics.recordDownloadDelivery("stream", result.fallbackBundle()); + if (result.fallbackBundle()) { + metrics.incrementBundleMissingFallback(); + } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") .contentType(MediaType.parseMediaType(result.contentType())) .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + .body(new InputStreamResource(result.openContent())); + } + + private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) { + if (presignedUrl == null || presignedUrl.isBlank()) { + return false; + } + if (!isSecureRequest(request)) { + return true; + } + try { + return "https".equalsIgnoreCase(URI.create(presignedUrl).getScheme()); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private boolean isSecureRequest(HttpServletRequest request) { + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + if (forwardedProto != null && !forwardedProto.isBlank()) { + return "https".equalsIgnoreCase(forwardedProto); + } + return request.isSecure(); + } + + private SkillLifecycleVersionResponse toLifecycleVersion(SkillLifecycleProjectionService.VersionProjection projection) { + if (projection == null) { + return null; + } + return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java new file mode 100644 index 00000000..adcc8224 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -0,0 +1,198 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +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.ReviewService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.dto.AdminSkillActionRequest; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import jakarta.validation.Valid; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RestController; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillLifecycleController extends BaseApiController { + + private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillGovernanceService skillGovernanceService; + private final ReviewService reviewService; + private final SkillPublishService skillPublishService; + private final AuditLogService auditLogService; + private final SkillSlugResolutionService skillSlugResolutionService; + + public SkillLifecycleController(NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, + SkillGovernanceService skillGovernanceService, + ReviewService reviewService, + SkillPublishService skillPublishService, + AuditLogService auditLogService, + SkillSlugResolutionService skillSlugResolutionService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillGovernanceService = skillGovernanceService; + this.reviewService = reviewService; + this.skillPublishService = skillPublishService; + this.auditLogService = auditLogService; + this.skillSlugResolutionService = skillSlugResolutionService; + } + + @PostMapping("/{namespace}/{slug}/archive") + public ApiResponse archiveSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody(required = false) AdminSkillActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug, userId); + Skill archived = skillGovernanceService.archiveSkill( + skill.getId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name())); + } + + @PostMapping("/{namespace}/{slug}/unarchive") + public ApiResponse unarchiveSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug, userId); + Skill restored = skillGovernanceService.unarchiveSkill( + skill.getId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name())); + } + + @DeleteMapping("/{namespace}/{slug}/versions/{version}") + public ApiResponse deleteVersion(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + skillGovernanceService.deleteVersion( + skill, + skillVersion, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + + return ok("response.success.deleted", + new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version)); + } + + @PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review") + public ApiResponse withdrawReview(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + SkillVersion withdrawnVersion = reviewService.withdrawReview(skillVersion.getId(), userId); + auditLogService.record( + userId, + "REVIEW_WITHDRAW", + "SKILL_VERSION", + skillVersion.getId(), + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", withdrawnVersion.getStatus().name())); + } + + @PostMapping("/{namespace}/{slug}/versions/{version}/rerelease") + public ApiResponse rereleaseVersion(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @Valid @RequestBody SkillVersionRereleaseRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion( + skill.getId(), + skillVersion.getVersion(), + request.targetVersion().trim(), + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + auditLogService.record( + userId, + "RERELEASE_SKILL_VERSION", + "SKILL_VERSION", + skillVersion.getId(), + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"sourceVersion\":\"" + version.replace("\"", "\\\"") + + "\",\"targetVersion\":\"" + request.targetVersion().trim().replace("\"", "\\\"") + "\"}" + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name())); + } + + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { + String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; + Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); + return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index 7d96a749..ced3ec4d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -9,7 +10,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -17,18 +20,21 @@ import java.util.List; @RestController -@RequestMapping("/api/v1/skills") +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; + private final SkillHubMetrics skillHubMetrics; public SkillPublishController(SkillPublishService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, - ApiResponseFactory responseFactory) { + ApiResponseFactory responseFactory, + SkillHubMetrics skillHubMetrics) { super(responseFactory); this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; + this.skillHubMetrics = skillHubMetrics; } @PostMapping("/{namespace}/publish") @@ -37,7 +43,7 @@ public ApiResponse publish( @PathVariable String namespace, @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") String userId) throws IOException { + @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -51,8 +57,9 @@ public ApiResponse publish( SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, entries, - userId, - skillVisibility + principal.userId(), + skillVisibility, + principal.platformRoles() ); PublishResponse response = new PublishResponse( @@ -64,6 +71,7 @@ public ApiResponse publish( publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index c25713df..88a48ca6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -13,7 +13,7 @@ import java.util.Optional; @RestController -@RequestMapping("/api/v1/skills") +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillRatingController extends BaseApiController { private final SkillRatingService skillRatingService; @@ -37,6 +37,9 @@ public ApiResponse rateSkill( public ApiResponse getUserRating( @PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + return ok("response.success.read", new SkillRatingStatusResponse((short) 0, false)); + } Optional rating = skillRatingService.getUserRating(skillId, principal.userId()); return ok( "response.success.read", diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java new file mode 100644 index 00000000..c6180ef6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillReportMutationResponse; +import com.iflytek.skillhub.dto.SkillReportSubmitRequest; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RestController; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillReportController extends BaseApiController { + + private final NamespaceRepository namespaceRepository; + private final SkillReportService skillReportService; + private final SkillSlugResolutionService skillSlugResolutionService; + + public SkillReportController(NamespaceRepository namespaceRepository, + SkillReportService skillReportService, + SkillSlugResolutionService skillSlugResolutionService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.namespaceRepository = namespaceRepository; + this.skillReportService = skillReportService; + this.skillSlugResolutionService = skillSlugResolutionService; + } + + @PostMapping("/{namespace}/{slug}/reports") + public ApiResponse submitReport(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody SkillReportSubmitRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug, userId); + var report = skillReportService.submitReport( + skill.getId(), + userId, + request.reason(), + request.details(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); + } + + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { + String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; + Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); + return skillSlugResolutionService.resolve( + namespace.getId(), + skillSlug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 505229ee..a3786170 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -6,12 +6,13 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController -@RequestMapping("/api/v1/skills") +@RequestMapping({"/api/web/skills"}) public class SkillSearchController extends BaseApiController { private final SkillSearchAppService skillSearchAppService; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index e9ce9709..8ffb88a9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/v1/skills") +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillStarController extends BaseApiController { private final SkillStarService skillStarService; @@ -40,6 +40,9 @@ public ApiResponse unstarSkill( public ApiResponse checkStarred( @PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + return ok("response.success.read", false); + } boolean starred = skillStarService.isStarred(skillId, principal.userId()); return ok("response.success.read", starred); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index 1882dac8..1edd7036 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillTag; import com.iflytek.skillhub.domain.skill.service.SkillTagService; import com.iflytek.skillhub.dto.ApiResponse; @@ -12,10 +13,14 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController -@RequestMapping("/api/v1/skills/{namespace}/{slug}/tags") +@RequestMapping({ + "/api/v1/skills/{namespace}/{slug}/tags", + "/api/web/skills/{namespace}/{slug}/tags" +}) public class SkillTagController extends BaseApiController { private final SkillTagService skillTagService; @@ -29,9 +34,16 @@ public SkillTagController(SkillTagService skillTagService, @GetMapping public ApiResponse> listTags( @PathVariable String namespace, - @PathVariable String slug) { + @PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - List tags = skillTagService.listTags(namespace, slug); + List tags = skillTagService.listTags( + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); List response = tags.stream() .map(TagResponse::from) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java new file mode 100644 index 00000000..d7edec9b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -0,0 +1,119 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public class MultipartPackageExtractor { + + private final SkillPublishProperties properties; + private final ObjectMapper objectMapper; + + public MultipartPackageExtractor(SkillPublishProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + } + + public record PublishPayload( + String slug, + String displayName, + String version, + String changelog, + Boolean acceptLicenseTerms, + List tags, + ForkOf forkOf + ) { + public record ForkOf(String slug, String version) {} + } + + public record ExtractedPackage(PublishPayload payload, List entries) {} + + public ExtractedPackage extract(MultipartFile[] files, String payloadJson) throws IOException { + PublishPayload payload = objectMapper.readValue(payloadJson, PublishPayload.class); + + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + if (files != null) { + for (MultipartFile file : files) { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizePath(originalFilename); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = file.getBytes(); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + if (content.length > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + normalizedPath + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + } + } + + return new ExtractedPackage(payload, entries); + } + + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + path = path.replace("\\", "/"); + } + + // Remove leading ./ + while (path.startsWith("./")) { + path = path.substring(2); + } + + if (path.isBlank() || path.startsWith("../") || path.equals("..") || path.startsWith("/") || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return path; + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index b9193b8e..9becbdd2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.support; +import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; @@ -8,18 +9,30 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @Component public class SkillPackageArchiveExtractor { + private final long maxTotalPackageSize; + private final long maxSingleFileSize; + private final int maxFileCount; + + public SkillPackageArchiveExtractor(SkillPublishProperties properties) { + this.maxTotalPackageSize = properties.getMaxPackageSize(); + this.maxSingleFileSize = properties.getMaxSingleFileSize(); + this.maxFileCount = properties.getMaxFileCount(); + } + public List extract(MultipartFile file) throws IOException { - if (file.getSize() > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + if (file.getSize() > maxTotalPackageSize) { throw new IllegalArgumentException( "Package too large: " + file.getSize() + " bytes (max: " - + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")" + + maxTotalPackageSize + ")" ); } @@ -34,19 +47,19 @@ public List extract(MultipartFile file) throws IOException { continue; } - if (entries.size() >= SkillPackagePolicy.MAX_FILE_COUNT) { + if (entries.size() >= maxFileCount) { throw new IllegalArgumentException( - "Too many files: more than " + SkillPackagePolicy.MAX_FILE_COUNT + "Too many files: more than " + maxFileCount ); } String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName()); byte[] content = readEntry(zis, normalizedPath); totalSize += content.length; - if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + if (totalSize > maxTotalPackageSize) { throw new IllegalArgumentException( "Package too large: " + totalSize + " bytes (max: " - + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")" + + maxTotalPackageSize + ")" ); } @@ -60,7 +73,38 @@ public List extract(MultipartFile file) throws IOException { } } - return entries; + return stripSingleRootDirectory(entries); + } + + /** + * If all file paths share a single root directory prefix (e.g., "my-skill/xxx"), + * strip that prefix. Otherwise return entries unchanged. + */ + static List stripSingleRootDirectory(List entries) { + if (entries.isEmpty()) return entries; + + Set rootSegments = new HashSet<>(); + for (PackageEntry entry : entries) { + int slashIndex = entry.path().indexOf('/'); + if (slashIndex < 0) { + // File at root level, no stripping + return entries; + } + rootSegments.add(entry.path().substring(0, slashIndex)); + } + + if (rootSegments.size() != 1) { + return entries; + } + + String prefix = rootSegments.iterator().next() + "/"; + return entries.stream() + .map(e -> new PackageEntry( + e.path().substring(prefix.length()), + e.content(), + e.size(), + e.contentType())) + .toList(); } private byte[] readEntry(ZipInputStream zis, String path) throws IOException { @@ -70,10 +114,10 @@ private byte[] readEntry(ZipInputStream zis, String path) throws IOException { int read; while ((read = zis.read(buffer)) != -1) { totalRead += read; - if (totalRead > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + if (totalRead > maxSingleFileSize) { throw new IllegalArgumentException( "File too large: " + path + " (" + totalRead + " bytes, max: " - + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")" + + maxSingleFileSize + ")" ); } outputStream.write(buffer, 0, read); @@ -82,11 +126,27 @@ private byte[] readEntry(ZipInputStream zis, String path) throws IOException { } private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; return "application/octet-stream"; } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java new file mode 100644 index 00000000..5d3a7a80 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -0,0 +1,143 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class ZipPackageExtractor { + + private static final int BUFFER_SIZE = 8192; + + private final SkillPublishProperties properties; + + public ZipPackageExtractor(SkillPublishProperties properties) { + this.properties = properties; + } + + public List extract(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (zipEntry.isDirectory()) { + zis.closeEntry(); + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizeEntryPath(zipEntry.getName()); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = readEntry(zis, normalizedPath); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + zis.closeEntry(); + } + } + + return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries); + } + + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + long fileSize = 0L; + while ((read = zis.read(buffer)) != -1) { + fileSize += read; + if (fileSize > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + + private String normalizeEntryPath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package entry must use '/' separators: " + path); + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.startsWith("../") + || normalizedPath.equals("..") + || path.startsWith("/") + || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return normalizedPath; + } catch (InvalidPathException ex) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Invalid package path: " + path); + } + } + + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java new file mode 100644 index 00000000..61d40da8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillActionRequest(String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java new file mode 100644 index 00000000..dff57c87 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillMutationResponse( + Long skillId, + Long versionId, + String action, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java new file mode 100644 index 00000000..f2a85e39 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillReportActionRequest( + String comment, + String disposition +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java new file mode 100644 index 00000000..91771d01 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record AdminSkillReportSummaryResponse( + Long id, + Long skillId, + String namespace, + String skillSlug, + String skillDisplayName, + String reporterId, + String reason, + String details, + String status, + String handledBy, + String handleComment, + Instant createdAt, + Instant handledAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java index 3cdbbfd2..6eee40e3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; public record AdminUserSummaryResponse( @@ -9,6 +9,6 @@ public record AdminUserSummaryResponse( String email, String status, List platformRoles, - LocalDateTime createdAt + Instant createdAt ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java index fa4abf81..720958f4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java @@ -5,28 +5,31 @@ import org.slf4j.MDC; import org.springframework.context.i18n.LocaleContextHolder; +import java.time.Clock; import java.time.Instant; @Component public class ApiResponseFactory { private final MessageSource messageSource; + private final Clock clock; - public ApiResponseFactory(MessageSource messageSource) { + public ApiResponseFactory(MessageSource messageSource, Clock clock) { this.messageSource = messageSource; + this.clock = clock; } public ApiResponse ok(String messageCode, T data, Object... args) { String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); - return new ApiResponse<>(0, msg, data, Instant.now(), MDC.get("requestId")); + return new ApiResponse<>(0, msg, data, Instant.now(clock), MDC.get("requestId")); } public ApiResponse error(int code, String messageCode, Object... args) { String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); - return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId")); + return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); } public ApiResponse errorMessage(int code, String msg) { - return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId")); + return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java index 699c1b19..b3657f43 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java @@ -9,6 +9,9 @@ public record AuditLogItemResponse( String username, String details, String ipAddress, + String requestId, + String resourceType, + String resourceId, Instant timestamp ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMethodResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMethodResponse.java new file mode 100644 index 00000000..143c5981 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMethodResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +public record AuthMethodResponse( + String id, + String methodType, + String provider, + String displayName, + String actionUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java new file mode 100644 index 00000000..e10efa74 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ChangePasswordRequest( + @NotBlank(message = "{validation.auth.local.currentPassword.notBlank}") + String currentPassword, + @NotBlank(message = "{validation.auth.local.newPassword.notBlank}") + String newPassword +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DirectLoginRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DirectLoginRequest.java new file mode 100644 index 00000000..82b8626f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DirectLoginRequest.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record DirectLoginRequest( + @NotBlank(message = "认证提供方不能为空") + String provider, + @NotBlank(message = "用户名不能为空") + String username, + @NotBlank(message = "密码不能为空") + String password +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java new file mode 100644 index 00000000..7ed30115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceActivityItemResponse( + Long id, + String action, + String actorUserId, + String actorDisplayName, + String targetType, + String targetId, + String details, + String timestamp +) { +} 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 new file mode 100644 index 00000000..c9298a5e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceInboxItemResponse( + String type, + Long id, + String title, + String subtitle, + String timestamp, + String namespace, + String skillSlug +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java new file mode 100644 index 00000000..696e9340 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceNotificationResponse( + Long id, + String category, + String entityType, + Long entityId, + String title, + String bodyJson, + String status, + String createdAt, + String readAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java new file mode 100644 index 00000000..5a32049d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceSummaryResponse( + long pendingReviews, + long pendingPromotions, + long pendingReports +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java new file mode 100644 index 00000000..62ba3b8d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LocalLoginRequest( + @NotBlank(message = "用户名不能为空") + String username, + @NotBlank(message = "密码不能为空") + String password +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java new file mode 100644 index 00000000..5a4a2749 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LocalRegisterRequest( + @NotBlank(message = "{validation.auth.local.username.notBlank}") + String username, + @NotBlank(message = "{validation.auth.local.password.notBlank}") + String password, + @Email(message = "{validation.auth.local.email.invalid}") + String email +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java index 39c24462..dfd39d35 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java @@ -3,15 +3,15 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import java.time.LocalDateTime; +import java.time.Instant; public record MemberResponse( Long id, Long namespaceId, String userId, NamespaceRole role, - LocalDateTime createdAt, - LocalDateTime updatedAt + Instant createdAt, + Instant updatedAt ) { public static MemberResponse from(NamespaceMember member) { return new MemberResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java new file mode 100644 index 00000000..588d7eb9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MergeInitiateRequest( + @NotBlank(message = "待合并账号标识不能为空") + String secondaryIdentifier +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java new file mode 100644 index 00000000..86e7fab8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record MergeInitiateResponse( + Long mergeRequestId, + String secondaryUserId, + String verificationToken, + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java new file mode 100644 index 00000000..3d0e63d4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MergeVerifyRequest( + @NotNull(message = "合并请求 ID 不能为空") + Long mergeRequestId, + @NotBlank(message = "验证 token 不能为空") + String verificationToken +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java new file mode 100644 index 00000000..09640c10 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; + +import java.time.Instant; + +public record MyNamespaceResponse( + Long id, + String slug, + String displayName, + NamespaceStatus status, + String description, + NamespaceType type, + String avatarUrl, + String createdBy, + Instant createdAt, + Instant updatedAt, + NamespaceRole currentUserRole, + boolean immutable, + boolean canFreeze, + boolean canUnfreeze, + boolean canArchive, + boolean canRestore +) { + public static MyNamespaceResponse from(Namespace namespace, + NamespaceRole currentUserRole, + NamespaceAccessPolicy accessPolicy) { + return new MyNamespaceResponse( + namespace.getId(), + namespace.getSlug(), + namespace.getDisplayName(), + namespace.getStatus(), + namespace.getDescription(), + namespace.getType(), + namespace.getAvatarUrl(), + namespace.getCreatedBy(), + namespace.getCreatedAt(), + namespace.getUpdatedAt(), + currentUserRole, + accessPolicy.isImmutable(namespace), + accessPolicy.canFreeze(namespace, currentUserRole), + accessPolicy.canUnfreeze(namespace, currentUserRole), + accessPolicy.canArchive(namespace, currentUserRole), + accessPolicy.canRestore(namespace, currentUserRole) + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java new file mode 100644 index 00000000..f65bcda0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.user.UserAccount; + +public record NamespaceCandidateUserResponse( + String userId, + String displayName, + String email, + String status +) { + public static NamespaceCandidateUserResponse from(UserAccount user) { + return new NamespaceCandidateUserResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getStatus().name() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java new file mode 100644 index 00000000..16c07882 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Size; + +public record NamespaceLifecycleRequest( + @Size(max = 512, message = "{validation.namespace.description.size}") + String reason +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java index 81158662..603a38da 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java @@ -4,7 +4,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; -import java.time.LocalDateTime; +import java.time.Instant; public record NamespaceResponse( Long id, @@ -15,8 +15,8 @@ public record NamespaceResponse( NamespaceType type, String avatarUrl, String createdBy, - LocalDateTime createdAt, - LocalDateTime updatedAt + Instant createdAt, + Instant updatedAt ) { public static NamespaceResponse from(Namespace namespace) { return new NamespaceResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SessionBootstrapRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SessionBootstrapRequest.java new file mode 100644 index 00000000..ee419b33 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SessionBootstrapRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SessionBootstrapRequest( + @NotBlank(message = "认证提供方不能为空") + String provider +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 6bdafbf6..70d8ae7e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.dto; +import java.math.BigDecimal; + public record SkillDetailResponse( Long id, String slug, @@ -9,6 +11,16 @@ public record SkillDetailResponse( String status, Long downloadCount, Integer starCount, - String latestVersion, - String namespace + BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, + String namespace, + boolean canManageLifecycle, + boolean canSubmitPromotion, + boolean canInteract, + boolean canReport, + SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, + SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java new file mode 100644 index 00000000..5cdea644 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record SkillLifecycleMutationResponse( + Long skillId, + Long versionId, + String action, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java new file mode 100644 index 00000000..aea959be --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +public record SkillLifecycleVersionResponse( + Long id, + String version, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java new file mode 100644 index 00000000..1445d957 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillReportMutationResponse( + Long reportId, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java new file mode 100644 index 00000000..8754b460 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillReportSubmitRequest( + String reason, + String details +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 99b7cf76..5d699a45 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -1,18 +1,23 @@ package com.iflytek.skillhub.dto; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; public record SkillSummaryResponse( Long id, String slug, String displayName, String summary, + String status, Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount, - String latestVersion, String namespace, - LocalDateTime updatedAt + Instant updatedAt, + boolean canSubmitPromotion, + SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, + SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java index c80f2a7b..8c01be78 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record SkillVersionDetailResponse( Long id, @@ -9,7 +9,7 @@ public record SkillVersionDetailResponse( String changelog, int fileCount, long totalSize, - LocalDateTime publishedAt, + Instant publishedAt, String parsedMetadataJson, String manifestJson ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java new file mode 100644 index 00000000..6ca9e708 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillVersionRereleaseRequest( + @NotBlank(message = "{validation.required}") + String targetVersion +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java index 77bb8e06..23fcb071 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record SkillVersionResponse( Long id, @@ -9,5 +9,6 @@ public record SkillVersionResponse( String changelog, int fileCount, long totalSize, - LocalDateTime publishedAt + Instant publishedAt, + boolean downloadAvailable ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java index 40f34ed0..e24cccc9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java @@ -2,13 +2,13 @@ import com.iflytek.skillhub.domain.skill.SkillTag; -import java.time.LocalDateTime; +import java.time.Instant; public record TagResponse( Long id, String tagName, Long versionId, - LocalDateTime createdAt + Instant createdAt ) { public static TagResponse from(SkillTag tag) { return new TagResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java index 63eadf18..cb48c855 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java @@ -1,11 +1,14 @@ package com.iflytek.skillhub.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; public record TokenCreateRequest( @NotBlank(message = "{validation.token.name.notBlank}") + @Size(max = 64, message = "{validation.token.name.size}") String name, - List scopes + List scopes, + String expiresAt ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenExpirationUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenExpirationUpdateRequest.java new file mode 100644 index 00000000..10ceb60a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenExpirationUpdateRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +public record TokenExpirationUpdateRequest( + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java new file mode 100644 index 00000000..eef266bc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public class BadRequestException extends LocalizedException { + + public BadRequestException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } + + @Override + public HttpStatus status() { + return HttpStatus.BAD_REQUEST; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java new file mode 100644 index 00000000..6c60cb10 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends LocalizedException { + + public ForbiddenException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } + + @Override + public HttpStatus status() { + return HttpStatus.FORBIDDEN; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 49625f63..ebf0f43c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -1,15 +1,23 @@ package com.iflytek.skillhub.exception; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; 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.metrics.SkillHubMetrics; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; +import com.iflytek.skillhub.storage.StorageAccessException; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -20,38 +28,56 @@ public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); private final ApiResponseFactory apiResponseFactory; + private final SensitiveLogSanitizer sensitiveLogSanitizer; + private final SkillHubMetrics metrics; - public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory) { + public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory, + SensitiveLogSanitizer sensitiveLogSanitizer, + SkillHubMetrics metrics) { this.apiResponseFactory = apiResponseFactory; + this.sensitiveLogSanitizer = sensitiveLogSanitizer; + this.metrics = metrics; } @ExceptionHandler(LocalizedException.class) - public ResponseEntity> handleLocalizedError(LocalizedException ex) { + public ResponseEntity> handleLocalizedError(LocalizedException ex, HttpServletRequest request) { HttpStatus status = ex.status(); + logHandledException(status, ex.messageCode(), request); return ResponseEntity.status(status).body( apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); } + @ExceptionHandler(AuthFlowException.class) + public ResponseEntity> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) { + HttpStatus status = ex.getStatus(); + logHandledException(status, ex.getMessageCode(), request); + return ResponseEntity.status(status).body( + apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs())); + } + @ExceptionHandler(DomainBadRequestException.class) - public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { + public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex, HttpServletRequest request) { + logHandledException(HttpStatus.BAD_REQUEST, ex.messageCode(), request); return ResponseEntity.badRequest().body( apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(DomainForbiddenException.class) - public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex) { + public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, ex.messageCode(), request); return ResponseEntity.status(HttpStatus.FORBIDDEN).body( apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(DomainNotFoundException.class) - public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex) { + public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex, HttpServletRequest request) { + logHandledException(HttpStatus.NOT_FOUND, ex.messageCode(), request); return ResponseEntity.status(HttpStatus.NOT_FOUND).body( apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { String msg = ex.getBindingResult().getFieldErrors().stream() .findFirst() .map(FieldError::getDefaultMessage) @@ -59,6 +85,7 @@ public ResponseEntity> handleValidation(MethodArgumentNotValid .findFirst() .map(error -> error.getDefaultMessage()) .orElse(null)); + logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request); if (msg == null || msg.isBlank()) { return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest")); } @@ -66,22 +93,76 @@ public ResponseEntity> handleValidation(MethodArgumentNotValid } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { + public ResponseEntity> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { + logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request); return ResponseEntity.badRequest().body( apiResponseFactory.error(400, "error.badRequest")); } @ExceptionHandler(SecurityException.class) - public ResponseEntity> handleForbidden(SecurityException ex) { + public ResponseEntity> handleForbidden(SecurityException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body( + apiResponseFactory.error(403, "error.forbidden")); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request); return ResponseEntity.status(HttpStatus.FORBIDDEN).body( apiResponseFactory.error(403, "error.forbidden")); } + @ExceptionHandler(StorageAccessException.class) + public ResponseEntity> handleStorageAccess(StorageAccessException ex, HttpServletRequest request) { + metrics.incrementStorageAccessFailure(ex.getOperation()); + logger.warn( + "Object storage unavailable [requestId={}, method={}, path={}, userId={}, operation={}, key={}]", + MDC.get("requestId"), + request.getMethod(), + sensitiveLogSanitizer.sanitizeRequestTarget(request), + resolveUserId(request), + ex.getOperation(), + ex.getKey(), + ex + ); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( + apiResponseFactory.error(503, "error.storage.unavailable")); + } + @ExceptionHandler(Exception.class) - public ResponseEntity> handleGlobalException(Exception ex) { - String requestId = MDC.get("requestId"); - logger.error("Unhandled exception [requestId={}]", requestId, ex); + public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) { + logger.error( + "Unhandled API exception [requestId={}, method={}, path={}, userId={}]", + MDC.get("requestId"), + request.getMethod(), + sensitiveLogSanitizer.sanitizeRequestTarget(request), + resolveUserId(request), + ex + ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( apiResponseFactory.error(500, "error.internal")); } + + private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) { + logger.info( + "API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]", + MDC.get("requestId"), + status.value(), + request.getMethod(), + sensitiveLogSanitizer.sanitizeRequestTarget(request), + resolveUserId(request), + messageCode + ); + } + + private String resolveUserId(HttpServletRequest request) { + if (!(request.getUserPrincipal() instanceof Authentication authentication)) { + return "anonymous"; + } + if (authentication.getPrincipal() instanceof PlatformPrincipal principal) { + return principal.userId(); + } + return authentication.getName(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 60d7ac6d..0338be30 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -1,18 +1,25 @@ package com.iflytek.skillhub.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Map; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -20,9 +27,21 @@ public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + private final ApiResponseFactory apiResponseFactory; + private final ObjectMapper objectMapper; + private final boolean enforceActiveUserCheck; - public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository) { + public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository, + ApiResponseFactory apiResponseFactory, + ObjectMapper objectMapper, + @Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck) { this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + this.apiResponseFactory = apiResponseFactory; + this.objectMapper = objectMapper; + this.enforceActiveUserCheck = enforceActiveUserCheck; } @Override @@ -32,6 +51,16 @@ protected void doFilterInternal( FilterChain filterChain) throws ServletException, IOException { PlatformPrincipal principal = resolvePrincipal(request); if (principal != null) { + if (isInactiveUser(principal.userId())) { + clearAuthentication(request); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue( + response.getOutputStream(), + apiResponseFactory.error(HttpServletResponse.SC_UNAUTHORIZED, "error.auth.local.accountDisabled") + ); + return; + } request.setAttribute("userId", principal.userId()); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( @@ -44,6 +73,26 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } + private boolean isInactiveUser(String userId) { + if (!enforceActiveUserCheck) { + return false; + } + return userAccountRepository.findById(userId) + .map(user -> !user.isActive()) + .orElse(true); + } + + private void clearAuthentication(HttpServletRequest request) { + SecurityContextHolder.clearContext(); + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + session.removeAttribute("platformPrincipal"); + session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + session.invalidate(); + } + private PlatformPrincipal resolvePrincipal(HttpServletRequest request) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java index 1e6e4d59..e9780f46 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import java.time.Clock; import java.time.Instant; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -25,13 +26,16 @@ public class IdempotencyInterceptor implements HandlerInterceptor { private final StringRedisTemplate redisTemplate; private final IdempotencyRecordRepository idempotencyRecordRepository; private final ObjectMapper objectMapper; + private final Clock clock; public IdempotencyInterceptor(StringRedisTemplate redisTemplate, IdempotencyRecordRepository idempotencyRecordRepository, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + Clock clock) { this.redisTemplate = redisTemplate; this.idempotencyRecordRepository = idempotencyRecordRepository; this.objectMapper = objectMapper; + this.clock = clock; } @Override @@ -73,7 +77,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } } else { // Create new record - Instant now = Instant.now(); + Instant now = Instant.now(clock); IdempotencyRecord newRecord = new IdempotencyRecord( requestId, (String) null, (Long) null, IdempotencyStatus.PROCESSING, (Integer) null, now, now.plusSeconds(EXPIRY_HOURS * 3600)); @@ -120,7 +124,7 @@ public void afterCompletion(HttpServletRequest request, HttpServletResponse resp private void writeDuplicateResponse(HttpServletResponse response) throws Exception { ApiResponse body = new ApiResponse<>(409, "error.request.duplicate", null, - Instant.now(), null); + Instant.now(clock), null); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(body)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java new file mode 100644 index 00000000..7783f3c3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + private static final int MAX_LOG_BODY_LENGTH = 512; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + ContentCachingRequestWrapper cachedRequest = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper cachedResponse = new ContentCachingResponseWrapper(response); + + long startTime = System.currentTimeMillis(); + + try { + filterChain.doFilter(cachedRequest, cachedResponse); + } finally { + long duration = System.currentTimeMillis() - startTime; + logRequest(cachedRequest, cachedResponse, duration); + cachedResponse.copyBodyToResponse(); + } + } + + private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) { + String requestUri = request.getRequestURI(); + String queryString = request.getQueryString(); + String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri; + + StringBuilder sb = new StringBuilder(); + sb.append("\n========== HTTP Request ==========\n"); + sb.append("URL: ").append(request.getMethod()).append(" ").append(fullUrl).append("\n"); + sb.append("Remote Address: ").append(request.getRemoteAddr()).append("\n"); + sb.append("Headers: ").append(getHeaders(request)).append("\n"); + + String requestBody = getRequestBody(request); + if (requestBody != null && !requestBody.isBlank()) { + sb.append("Request Body: ").append(requestBody).append("\n"); + } + + sb.append("Response Status: ").append(response.getStatus()).append("\n"); + + String responseBody = getResponseBody(response); + if (responseBody != null && !responseBody.isBlank()) { + sb.append("Response Body: ").append(responseBody).append("\n"); + } + + sb.append("Duration: ").append(duration).append("ms\n"); + sb.append("==================================="); + + log.info(sb.toString()); + } + + private Map getHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headers.put(headerName, request.getHeader(headerName)); + } + return headers; + } + + private String getRequestBody(ContentCachingRequestWrapper request) { + byte[] buf = request.getContentAsByteArray(); + if (buf.length > 0) { + try { + return truncateBody(new String(buf, request.getCharacterEncoding())); + } catch (UnsupportedEncodingException e) { + return "[unknown encoding]"; + } + } + return null; + } + + private String getResponseBody(ContentCachingResponseWrapper response) { + byte[] buf = response.getContentAsByteArray(); + if (buf.length > 0) { + try { + return truncateBody(new String(buf, response.getCharacterEncoding())); + } catch (UnsupportedEncodingException e) { + return "[unknown encoding]"; + } + } + return null; + } + + private String truncateBody(String body) { + if (body == null || body.length() <= MAX_LOG_BODY_LENGTH) { + return body; + } + return body.substring(0, MAX_LOG_BODY_LENGTH) + + "... [truncated, original length=" + body.length() + "]"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java new file mode 100644 index 00000000..ab9714fc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class SkillHubMetrics { + + private final MeterRegistry meterRegistry; + + public SkillHubMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + public void incrementUserRegister() { + meterRegistry.counter("skillhub.user.register").increment(); + } + + public void recordLocalLogin(boolean success) { + meterRegistry.counter( + "skillhub.auth.login", + "method", "local", + "result", success ? "success" : "failure" + ).increment(); + } + + public void incrementSkillPublish(String namespace, String status) { + meterRegistry.counter( + "skillhub.skill.publish", + "namespace", namespace, + "status", status + ).increment(); + } + + public void recordDownloadDelivery(String mode, boolean fallbackBundle) { + meterRegistry.counter( + "skillhub.skill.download.delivery", + "mode", mode, + "fallback_bundle", Boolean.toString(fallbackBundle) + ).increment(); + } + + public void incrementBundleMissingFallback() { + meterRegistry.counter("skillhub.skill.download.bundle_missing_fallback").increment(); + } + + public void incrementRateLimitExceeded(String category) { + meterRegistry.counter( + "skillhub.ratelimit.exceeded", + "category", category + ).increment(); + } + + public void incrementStorageAccessFailure(String operation) { + meterRegistry.counter( + "skillhub.storage.failure", + "operation", operation + ).increment(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java new file mode 100644 index 00000000..dee29900 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java @@ -0,0 +1,131 @@ +package com.iflytek.skillhub.ratelimit; + +import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class AnonymousDownloadIdentityService { + + private static final String COOKIE_VERSION = "v1"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final DownloadRateLimitProperties properties; + private final ClientIpResolver clientIpResolver; + + public AnonymousDownloadIdentityService(DownloadRateLimitProperties properties, + ClientIpResolver clientIpResolver) { + this.properties = properties; + this.clientIpResolver = clientIpResolver; + } + + public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) { + String ip = clientIpResolver.resolve(request); + String cookieId = extractValidCookieId(request); + if (cookieId == null) { + cookieId = generateId(); + response.addHeader("Set-Cookie", buildCookie(cookieId, request).toString()); + } + return new AnonymousDownloadIdentity(hash(ip), hash(cookieId)); + } + + private String extractValidCookieId(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + return Arrays.stream(cookies) + .filter(cookie -> properties.getAnonymousCookieName().equals(cookie.getName())) + .map(Cookie::getValue) + .map(this::parseAndVerify) + .filter(value -> value != null && !value.isBlank()) + .findFirst() + .orElse(null); + } + + private String parseAndVerify(String cookieValue) { + if (cookieValue == null) { + return null; + } + String[] parts = cookieValue.split("\\.", 3); + if (parts.length != 3 || !COOKIE_VERSION.equals(parts[0])) { + return null; + } + byte[] expected = sign(parts[1]); + byte[] actual; + try { + actual = Base64.getUrlDecoder().decode(parts[2]); + } catch (IllegalArgumentException ex) { + return null; + } + return MessageDigest.isEqual(expected, actual) ? parts[1] : null; + } + + private ResponseCookie buildCookie(String cookieId, HttpServletRequest request) { + Duration maxAge = properties.getAnonymousCookieMaxAge(); + return ResponseCookie.from(properties.getAnonymousCookieName(), encodeCookieValue(cookieId)) + .httpOnly(true) + .secure(isSecure(request)) + .sameSite("Lax") + .path("/") + .maxAge(maxAge) + .build(); + } + + private boolean isSecure(HttpServletRequest request) { + if (request.isSecure()) { + return true; + } + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + return forwardedProto != null && forwardedProto.equalsIgnoreCase("https"); + } + + private String encodeCookieValue(String id) { + return COOKIE_VERSION + "." + id + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sign(id)); + } + + private byte[] sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(properties.getAnonymousCookieSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Failed to sign anonymous download cookie", ex); + } + } + + private String generateId() { + byte[] bytes = new byte[16]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hash(String raw) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Failed to hash anonymous download identity", ex); + } + } + + public record AnonymousDownloadIdentity(String ipHash, String cookieHash) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java new file mode 100644 index 00000000..816b6e3c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +@Component +public class ClientIpResolver { + + private static final Pattern FORWARDED_FOR_PATTERN = Pattern.compile("for=\"?\\[?([^;,\"]+)\\]?\"?"); + + public String resolve(HttpServletRequest request) { + String forwarded = trimToNull(request.getHeader("Forwarded")); + if (forwarded != null) { + Matcher matcher = FORWARDED_FOR_PATTERN.matcher(forwarded); + if (matcher.find()) { + return normalizeCandidate(matcher.group(1)); + } + } + + String xForwardedFor = trimToNull(request.getHeader("X-Forwarded-For")); + if (xForwardedFor != null) { + return normalizeCandidate(xForwardedFor.split(",")[0]); + } + + String xRealIp = trimToNull(request.getHeader("X-Real-IP")); + if (xRealIp != null) { + return normalizeCandidate(xRealIp); + } + + return normalizeCandidate(request.getRemoteAddr()); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() || "unknown".equalsIgnoreCase(trimmed) ? null : trimmed; + } + + private String normalizeCandidate(String candidate) { + String normalized = trimToNull(candidate); + if (normalized == null) { + return "unknown"; + } + int zoneIndex = normalized.indexOf('%'); + if (zoneIndex >= 0) { + normalized = normalized.substring(0, zoneIndex); + } + return normalized; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java index f1cf5ef7..8aa5be3d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; @@ -10,20 +11,32 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +import java.util.Map; @Component public class RateLimitInterceptor implements HandlerInterceptor { private final RateLimiter rateLimiter; + private final ClientIpResolver clientIpResolver; + private final AnonymousDownloadIdentityService anonymousDownloadIdentityService; private final ApiResponseFactory apiResponseFactory; private final ObjectMapper objectMapper; + private final SkillHubMetrics metrics; public RateLimitInterceptor(RateLimiter rateLimiter, + ClientIpResolver clientIpResolver, + AnonymousDownloadIdentityService anonymousDownloadIdentityService, ApiResponseFactory apiResponseFactory, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + SkillHubMetrics metrics) { this.rateLimiter = rateLimiter; + this.clientIpResolver = clientIpResolver; + this.anonymousDownloadIdentityService = anonymousDownloadIdentityService; this.apiResponseFactory = apiResponseFactory; this.objectMapper = objectMapper; + this.metrics = metrics; } @Override @@ -45,15 +58,17 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons // Get limit based on authentication status int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous(); + String resourceSuffix = resolveResourceSuffix(rateLimit.category(), request); - // Build rate limit key - String identifier = isAuthenticated ? "user:" + userId : "ip:" + getClientIp(request); - String key = "ratelimit:" + rateLimit.category() + ":" + identifier; - - // Check rate limit - boolean allowed = rateLimiter.tryAcquire(key, limit, rateLimit.windowSeconds()); + boolean allowed = isAuthenticated + ? rateLimiter.tryAcquire( + "ratelimit:" + rateLimit.category() + ":user:" + userId + resourceSuffix, + limit, + rateLimit.windowSeconds()) + : checkAnonymousLimit(request, response, rateLimit, limit, resourceSuffix); if (!allowed) { + metrics.incrementRateLimitExceeded(rateLimit.category()); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); ApiResponse body = apiResponseFactory.error(429, "error.rateLimit.exceeded"); @@ -64,18 +79,61 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - private String getClientIp(HttpServletRequest request) { - String ip = request.getHeader("X-Forwarded-For"); - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("X-Real-IP"); + private boolean checkAnonymousLimit(HttpServletRequest request, + HttpServletResponse response, + RateLimit rateLimit, + int limit, + String resourceSuffix) { + if (!"download".equals(rateLimit.category())) { + return rateLimiter.tryAcquire( + "ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix, + limit, + rateLimit.windowSeconds() + ); } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getRemoteAddr(); + + AnonymousDownloadIdentityService.AnonymousDownloadIdentity identity = + anonymousDownloadIdentityService.resolve(request, response); + boolean ipAllowed = rateLimiter.tryAcquire( + "ratelimit:download:ip:" + identity.ipHash() + resourceSuffix, + limit, + rateLimit.windowSeconds() + ); + if (!ipAllowed) { + return false; } - // Take first IP if multiple - if (ip != null && ip.contains(",")) { - ip = ip.split(",")[0].trim(); + return rateLimiter.tryAcquire( + "ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix, + limit, + rateLimit.windowSeconds() + ); + } + + @SuppressWarnings("unchecked") + private String resolveResourceSuffix(String category, HttpServletRequest request) { + if (!"download".equals(category)) { + return ""; + } + Object attribute = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (!(attribute instanceof Map templateVariables)) { + return ""; + } + String namespace = stringValue(templateVariables.get("namespace")); + String slug = stringValue(templateVariables.get("slug")); + String version = stringValue(templateVariables.get("version")); + String tagName = stringValue(templateVariables.get("tagName")); + if (namespace == null || slug == null) { + return ""; + } + String target = version != null ? "version:" + version : tagName != null ? "tag:" + tagName : "latest"; + return ":ns:" + namespace + ":slug:" + slug + ":" + target; + } + + private String stringValue(Object value) { + if (value == null) { + return null; } - return ip; + String text = value.toString(); + return text.isBlank() ? null : text; } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index a5d36a73..44a7c626 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -15,18 +18,30 @@ @Component public class ApiAccessDeniedHandler implements AccessDeniedHandler { + private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class); private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; + private final SensitiveLogSanitizer sensitiveLogSanitizer; - public ApiAccessDeniedHandler(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) { + public ApiAccessDeniedHandler(ObjectMapper objectMapper, + ApiResponseFactory apiResponseFactory, + SensitiveLogSanitizer sensitiveLogSanitizer) { this.objectMapper = objectMapper; this.apiResponseFactory = apiResponseFactory; + this.sensitiveLogSanitizer = sensitiveLogSanitizer; } @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + logger.info( + "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + MDC.get("requestId"), + request.getMethod(), + sensitiveLogSanitizer.sanitizeRequestTarget(request), + accessDeniedException.getClass().getSimpleName() + ); ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index d9bfe089..2611d3bf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -15,18 +18,30 @@ @Component public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class); private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; + private final SensitiveLogSanitizer sensitiveLogSanitizer; - public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) { + public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, + ApiResponseFactory apiResponseFactory, + SensitiveLogSanitizer sensitiveLogSanitizer) { this.objectMapper = objectMapper; this.apiResponseFactory = apiResponseFactory; + this.sensitiveLogSanitizer = sensitiveLogSanitizer; } @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + logger.info( + "Unauthorized API request [requestId={}, method={}, path={}, reason={}]", + MDC.get("requestId"), + request.getMethod(), + sensitiveLogSanitizer.sanitizeRequestTarget(request), + authException.getClass().getSimpleName() + ); ApiResponse body = apiResponseFactory.error(401, "error.auth.required"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java new file mode 100644 index 00000000..19997a4b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.security; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import java.time.Duration; +import java.util.Locale; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class AuthFailureThrottleService { + + private static final Duration WINDOW = Duration.ofMinutes(15); + private static final int IDENTIFIER_LIMIT = 8; + private static final int IP_LIMIT = 30; + + private final StringRedisTemplate redisTemplate; + + public AuthFailureThrottleService(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void assertAllowed(String category, String identifier, String clientIp) { + if (isLimited(identifierKey(category, identifier), IDENTIFIER_LIMIT) + || isLimited(ipKey(category, clientIp), IP_LIMIT)) { + throw new AuthFlowException(HttpStatus.TOO_MANY_REQUESTS, "error.auth.login.throttled", remainingMinutes(category, identifier, clientIp)); + } + } + + public void recordFailure(String category, String identifier, String clientIp) { + increment(identifierKey(category, identifier)); + increment(ipKey(category, clientIp)); + } + + public void resetIdentifier(String category, String identifier) { + String key = identifierKey(category, identifier); + if (key != null) { + redisTemplate.delete(key); + } + } + + private boolean isLimited(String key, int limit) { + if (key == null) { + return false; + } + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return false; + } + try { + return Integer.parseInt(value) >= limit; + } catch (NumberFormatException ignored) { + redisTemplate.delete(key); + return false; + } + } + + private void increment(String key) { + if (key == null) { + return; + } + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, WINDOW); + } + } + + private long remainingMinutes(String category, String identifier, String clientIp) { + long identifierMinutes = remainingMinutes(identifierKey(category, identifier)); + long ipMinutes = remainingMinutes(ipKey(category, clientIp)); + return Math.max(1, Math.max(identifierMinutes, ipMinutes)); + } + + private long remainingMinutes(String key) { + if (key == null) { + return 1; + } + Long seconds = redisTemplate.getExpire(key); + if (seconds == null || seconds <= 0) { + return 1; + } + return Math.max(1, (seconds + 59) / 60); + } + + private String identifierKey(String category, String identifier) { + if (!StringUtils.hasText(identifier)) { + return null; + } + return "auth-failure:" + category + ":id:" + identifier.trim().toLowerCase(Locale.ROOT); + } + + private String ipKey(String category, String clientIp) { + if (!StringUtils.hasText(clientIp)) { + return null; + } + return "auth-failure:" + category + ":ip:" + clientIp.trim(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java new file mode 100644 index 00000000..9b7efe73 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java @@ -0,0 +1,45 @@ +package com.iflytek.skillhub.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class SensitiveLogSanitizer { + + private static final Set SENSITIVE_KEYS = Set.of( + "password", "passwd", "pwd", "token", "authorization", "cookie", + "secret", "api_key", "apikey", "access_key", "refresh_token", "code"); + + public String sanitizeRequestTarget(HttpServletRequest request) { + String uri = request.getRequestURI(); + String query = request.getQueryString(); + if (!StringUtils.hasText(query)) { + return uri; + } + return uri + "?" + sanitizeQuery(query); + } + + String sanitizeQuery(String query) { + return Arrays.stream(query.split("&")) + .map(this::sanitizeQueryPart) + .collect(Collectors.joining("&")); + } + + private String sanitizeQueryPart(String queryPart) { + int idx = queryPart.indexOf('='); + if (idx < 0) { + return queryPart; + } + String key = queryPart.substring(0, idx); + String normalizedKey = key.trim().toLowerCase(Locale.ROOT); + if (SENSITIVE_KEYS.contains(normalizedKey)) { + return key + "=[REDACTED]"; + } + return queryPart; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index d1673617..b811e9d7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -10,6 +10,7 @@ import java.sql.Timestamp; import java.time.Instant; +import java.util.Collection; import java.util.List; @Service @@ -22,12 +23,44 @@ public AdminAuditLogAppService(NamedParameterJdbcTemplate namedParameterJdbcTemp } @Transactional(readOnly = true) - public PageResponse listAuditLogs(int page, int size, String userId, String action) { + public PageResponse listAuditLogs(int page, + int size, + String userId, + String action, + String requestId, + String ipAddress, + String resourceType, + String resourceId, + Instant startTime, + Instant endTime) { + return listAuditLogsByActions(page, size, userId, action != null ? List.of(action) : null, requestId, ipAddress, resourceType, resourceId, startTime, endTime); + } + + @Transactional(readOnly = true) + public PageResponse listAuditLogsByActions(int page, + int size, + String userId, + Collection actions, + String requestId, + String ipAddress, + String resourceType, + String resourceId, + Instant startTime, + Instant endTime) { MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue("limit", size) .addValue("offset", Math.max(page, 0) * size); - String whereClause = buildWhereClause(parameters, userId, action); + String whereClause = buildWhereClause( + parameters, + userId, + actions, + requestId, + ipAddress, + resourceType, + resourceId, + startTime, + endTime); Long total = namedParameterJdbcTemplate.queryForObject( "SELECT COUNT(*) FROM audit_log al" + whereClause, parameters, @@ -43,6 +76,7 @@ public PageResponse listAuditLogs(int page, int size, Stri al.detail_json, al.target_type, al.target_id, + al.request_id, al.client_ip, al.created_at FROM audit_log al @@ -62,21 +96,56 @@ public PageResponse listAuditLogs(int page, int size, Stri rs.getString("target_type"), rs.getObject("target_id")), rs.getString("client_ip"), + rs.getString("request_id"), + rs.getString("target_type"), + toResourceId(rs.getObject("target_id")), toInstant(rs.getTimestamp("created_at"))) ); return new PageResponse<>(items, total == null ? 0 : total, page, size); } - private String buildWhereClause(MapSqlParameterSource parameters, String userId, String action) { + private String buildWhereClause(MapSqlParameterSource parameters, + String userId, + Collection actions, + String requestId, + String ipAddress, + String resourceType, + String resourceId, + Instant startTime, + Instant endTime) { StringBuilder clause = new StringBuilder(" WHERE 1 = 1"); if (StringUtils.hasText(userId)) { clause.append(" AND al.actor_user_id = :userId"); parameters.addValue("userId", userId.trim()); } - if (StringUtils.hasText(action)) { - clause.append(" AND al.action = :action"); - parameters.addValue("action", action.trim()); + if (actions != null && !actions.isEmpty()) { + clause.append(" AND al.action IN (:actions)"); + parameters.addValue("actions", actions.stream().filter(StringUtils::hasText).map(String::trim).toList()); + } + if (StringUtils.hasText(requestId)) { + clause.append(" AND al.request_id = :requestId"); + parameters.addValue("requestId", requestId.trim()); + } + if (StringUtils.hasText(ipAddress)) { + clause.append(" AND al.client_ip = :ipAddress"); + parameters.addValue("ipAddress", ipAddress.trim()); + } + if (StringUtils.hasText(resourceType)) { + clause.append(" AND al.target_type = :resourceType"); + parameters.addValue("resourceType", resourceType.trim()); + } + if (StringUtils.hasText(resourceId)) { + clause.append(" AND CAST(al.target_id AS TEXT) = :resourceId"); + parameters.addValue("resourceId", resourceId.trim()); + } + if (startTime != null) { + clause.append(" AND al.created_at >= :startTime"); + parameters.addValue("startTime", Timestamp.from(startTime)); + } + if (endTime != null) { + clause.append(" AND al.created_at <= :endTime"); + parameters.addValue("endTime", Timestamp.from(endTime)); } return clause.toString(); } @@ -94,4 +163,8 @@ private String renderDetails(String detailJson, String targetType, Object target private Instant toInstant(Timestamp timestamp) { return timestamp == null ? null : timestamp.toInstant(); } + + private String toResourceId(Object targetId) { + return targetId == null ? null : String.valueOf(targetId); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java new file mode 100644 index 00000000..fe807e4f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java @@ -0,0 +1,86 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class AdminSkillReportAppService { + + private final SkillReportRepository skillReportRepository; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + + public AdminSkillReportAppService(SkillReportRepository skillReportRepository, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository) { + this.skillReportRepository = skillReportRepository; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + } + + public PageResponse listReports(String status, int page, int size) { + SkillReportStatus resolvedStatus = parseStatus(status); + var reportPage = skillReportRepository.findByStatus(resolvedStatus, PageRequest.of(page, size)); + + List skillIds = reportPage.getContent().stream().map(SkillReport::getSkillId).distinct().toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream().collect(Collectors.toMap(Skill::getId, Function.identity())); + + List namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList(); + Map namespaceSlugs = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream().collect(Collectors.toMap(Namespace::getId, Namespace::getSlug)); + + List items = reportPage.getContent().stream() + .map(report -> toResponse(report, skillsById.get(report.getSkillId()), namespaceSlugs)) + .toList(); + + return new PageResponse<>(items, reportPage.getTotalElements(), reportPage.getNumber(), reportPage.getSize()); + } + + private AdminSkillReportSummaryResponse toResponse(SkillReport report, + Skill skill, + Map namespaceSlugs) { + return new AdminSkillReportSummaryResponse( + report.getId(), + report.getSkillId(), + skill != null ? namespaceSlugs.get(skill.getNamespaceId()) : null, + skill != null ? skill.getSlug() : null, + skill != null ? skill.getDisplayName() : null, + report.getReporterId(), + report.getReason(), + report.getDetails(), + report.getStatus().name(), + report.getHandledBy(), + report.getHandleComment(), + report.getCreatedAt(), + report.getHandledAt() + ); + } + + private SkillReportStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return SkillReportStatus.PENDING; + } + try { + return SkillReportStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.skill.report.status.invalid", status); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 5d7a140d..7b649576 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.TreeSet; import java.util.Set; import java.util.stream.Collectors; @@ -130,12 +131,29 @@ private Map> loadRolesByUserId(List userIds) { if (userIds.isEmpty()) { return Map.of(); } - return userRoleBindingRepository.findByUserIdIn(userIds).stream() + Map> explicitRolesByUserId = userRoleBindingRepository.findByUserIdIn(userIds).stream() .collect(Collectors.groupingBy( UserRoleBinding::getUserId, Collectors.mapping(binding -> binding.getRole().getCode(), Collectors.collectingAndThen(Collectors.toList(), roles -> roles.stream().sorted().toList())))); + return userIds.stream().collect(Collectors.toMap( + userId -> userId, + userId -> withDefaultUserRole(explicitRolesByUserId.getOrDefault(userId, List.of())).stream() + .sorted() + .toList() + )); + } + + private Set withDefaultUserRole(List roles) { + Set resolvedRoles = new TreeSet<>(); + if (roles != null) { + resolvedRoles.addAll(roles); + } + if (resolvedRoles.isEmpty()) { + resolvedRoles.add("USER"); + } + return Set.copyOf(resolvedRoles); } private UserAccount loadUser(String userId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java new file mode 100644 index 00000000..fe7133f7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.TreeSet; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AdminUserManagementService { + + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final RoleRepository roleRepository; + + public AdminUserManagementService(UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + RoleRepository roleRepository) { + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.roleRepository = roleRepository; + } + + @Transactional(readOnly = true) + public PageResponse listUsers(String keyword, String status, int page, int size) { + UserStatus userStatus = parseStatus(status); + Page users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size)); + List items = users.getContent().stream() + .map(this::toSummary) + .toList(); + return PageResponse.from(new PageImpl<>(items, users.getPageable(), users.getTotalElements())); + } + + @Transactional + public AdminUserSummaryResponse updateUserRole(String userId, String roleCode, PlatformPrincipal principal) { + UserAccount user = loadUser(userId); + if (principal != null + && !principal.platformRoles().contains("SUPER_ADMIN") + && "SUPER_ADMIN".equalsIgnoreCase(roleCode)) { + throw new DomainForbiddenException("error.admin.role.assign_super_admin_forbidden"); + } + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new DomainBadRequestException("error.role.notFound", roleCode)); + + List existing = userRoleBindingRepository.findByUserId(userId); + boolean alreadyAssigned = existing.stream().anyMatch(binding -> binding.getRole().getCode().equals(roleCode)); + if (!alreadyAssigned) { + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } + return toSummary(user); + } + + @Transactional + public AdminUserSummaryResponse approveUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse updateUserStatus(String userId, String status) { + UserAccount user = loadUser(userId); + user.setStatus(parseRequiredStatus(status)); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse disableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.DISABLED); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse enableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + private UserAccount loadUser(String userId) { + return userAccountRepository.findById(userId) + .orElseThrow(() -> new DomainNotFoundException("error.user.notFound", userId)); + } + + private AdminUserSummaryResponse toSummary(UserAccount user) { + Set roles = new LinkedHashSet<>(); + userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .sorted(Comparator.naturalOrder()) + .forEach(roles::add); + roles = new LinkedHashSet<>(withDefaultUserRole(roles)); + return new AdminUserSummaryResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getStatus().name(), + List.copyOf(roles), + user.getCreatedAt() + ); + } + + private String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } + + private Set withDefaultUserRole(Set roles) { + Set resolvedRoles = new TreeSet<>(); + if (roles != null) { + resolvedRoles.addAll(roles); + } + if (resolvedRoles.isEmpty()) { + resolvedRoles.add("USER"); + } + return Set.copyOf(resolvedRoles); + } + + private UserStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return parseRequiredStatus(status); + } + + private UserStatus parseRequiredStatus(String status) { + try { + return UserStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.user.status.invalid", status); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java new file mode 100644 index 00000000..3e1f53c8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -0,0 +1,111 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.config.DirectAuthProperties; +import com.iflytek.skillhub.dto.AuthMethodResponse; +import com.iflytek.skillhub.dto.AuthProviderResponse; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.stereotype.Service; + +@Service +public class AuthMethodCatalog { + + private final OAuth2ClientProperties oAuth2ClientProperties; + private final DirectAuthProperties directAuthProperties; + private final AuthSessionBootstrapProperties sessionBootstrapProperties; + private final List directAuthProviders; + private final List passiveSessionAuthenticators; + + public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties, + DirectAuthProperties directAuthProperties, + AuthSessionBootstrapProperties sessionBootstrapProperties, + List directAuthProviders, + List passiveSessionAuthenticators) { + this.oAuth2ClientProperties = oAuth2ClientProperties; + this.directAuthProperties = directAuthProperties; + this.sessionBootstrapProperties = sessionBootstrapProperties; + this.directAuthProviders = directAuthProviders; + this.passiveSessionAuthenticators = passiveSessionAuthenticators; + } + + public List listOAuthProviders(String returnTo) { + String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); + return new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getKey())) + .map(entry -> new AuthProviderResponse( + entry.getKey(), + entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() + ? entry.getValue().getClientName() + : entry.getKey(), + buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + )) + .toList()); + } + + public List listMethods(String returnTo) { + String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); + List methods = new ArrayList<>(); + + methods.add(new AuthMethodResponse( + "local-password", + "PASSWORD", + "local", + "Local Account", + "/api/v1/auth/local/login" + )); + + oAuth2ClientProperties.getRegistration().entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getKey())) + .forEach(entry -> methods.add(new AuthMethodResponse( + "oauth-" + entry.getKey(), + "OAUTH_REDIRECT", + entry.getKey(), + entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() + ? entry.getValue().getClientName() + : entry.getKey(), + buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + ))); + + if (directAuthProperties.isEnabled()) { + directAuthProviders.stream() + .sorted(Comparator.comparing(DirectAuthProvider::providerCode)) + .forEach(provider -> methods.add(new AuthMethodResponse( + "direct-" + provider.providerCode(), + "DIRECT_PASSWORD", + provider.providerCode(), + provider.displayName(), + "/api/v1/auth/direct/login" + ))); + } + + if (sessionBootstrapProperties.isEnabled()) { + passiveSessionAuthenticators.stream() + .sorted(Comparator.comparing(PassiveSessionAuthenticator::providerCode)) + .forEach(provider -> methods.add(new AuthMethodResponse( + "bootstrap-" + provider.providerCode(), + "SESSION_BOOTSTRAP", + provider.providerCode(), + provider.displayName(), + "/api/v1/auth/session/bootstrap" + ))); + } + + return methods; + } + + private String buildAuthorizationUrl(String registrationId, String returnTo) { + String baseUrl = "/oauth2/authorization/" + registrationId; + if (returnTo == null) { + return baseUrl; + } + return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java new file mode 100644 index 00000000..3eb85795 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.config.DirectAuthProperties; +import com.iflytek.skillhub.exception.BadRequestException; +import com.iflytek.skillhub.exception.ForbiddenException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.springframework.stereotype.Service; + +@Service +public class DirectAuthService { + + private final DirectAuthProperties properties; + private final Map providersByCode; + private final SessionBootstrapService sessionBootstrapService; + + public DirectAuthService(DirectAuthProperties properties, + List providers, + SessionBootstrapService sessionBootstrapService) { + this.properties = properties; + this.providersByCode = providers.stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + DirectAuthProvider::providerCode, + Function.identity() + )); + this.sessionBootstrapService = sessionBootstrapService; + } + + public PlatformPrincipal authenticate(String providerCode, + String username, + String password, + HttpServletRequest request) { + if (!properties.isEnabled()) { + throw new ForbiddenException("error.auth.direct.disabled"); + } + + DirectAuthProvider provider = providersByCode.get(providerCode); + if (provider == null) { + throw new BadRequestException("error.auth.direct.providerUnsupported", providerCode); + } + + PlatformPrincipal principal = provider.authenticate(new DirectAuthRequest(username, password)); + sessionBootstrapService.establishSession(principal, request); + return principal; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java new file mode 100644 index 00000000..f11983dc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java @@ -0,0 +1,236 @@ +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.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +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.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class GovernanceWorkbenchAppService { + + private static final int SUMMARY_PAGE_SIZE = 100; + private static final Set ACTIVITY_ACTIONS = Set.of( + "REVIEW_SUBMIT", + "REVIEW_APPROVE", + "REVIEW_REJECT", + "REVIEW_WITHDRAW", + "PROMOTION_SUBMIT", + "PROMOTION_APPROVE", + "PROMOTION_REJECT", + "REPORT_SKILL", + "RESOLVE_SKILL_REPORT", + "DISMISS_SKILL_REPORT", + "HIDE_SKILL", + "ARCHIVE_SKILL", + "UNHIDE_SKILL", + "UNARCHIVE_SKILL" + ); + + private final ReviewTaskRepository reviewTaskRepository; + private final PromotionRequestRepository promotionRequestRepository; + private final SkillReportRepository skillReportRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final AdminAuditLogAppService adminAuditLogAppService; + + public GovernanceWorkbenchAppService(ReviewTaskRepository reviewTaskRepository, + PromotionRequestRepository promotionRequestRepository, + SkillReportRepository skillReportRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + AdminAuditLogAppService adminAuditLogAppService) { + this.reviewTaskRepository = reviewTaskRepository; + this.promotionRequestRepository = promotionRequestRepository; + this.skillReportRepository = skillReportRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.adminAuditLogAppService = adminAuditLogAppService; + } + + public GovernanceSummaryResponse getSummary(String userId, + Map namespaceRoles, + Set platformRoles) { + return new GovernanceSummaryResponse( + visiblePendingReviews(namespaceRoles, platformRoles, SUMMARY_PAGE_SIZE).getTotalElements(), + hasPlatformGovernanceRole(platformRoles) + ? promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements() + : 0, + hasPlatformGovernanceRole(platformRoles) + ? skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements() + : 0 + ); + } + + public PageResponse listInbox(String userId, + Map namespaceRoles, + Set platformRoles, + String type, + int page, + int size) { + List items = new ArrayList<>(); + boolean includeAll = type == null || type.isBlank(); + if (includeAll || "REVIEW".equalsIgnoreCase(type)) { + visiblePendingReviews(namespaceRoles, platformRoles, size).getContent().stream() + .map(this::toReviewInboxItem) + .forEach(items::add); + } + if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "PROMOTION".equalsIgnoreCase(type))) { + promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(page, size)).getContent().stream() + .map(this::toPromotionInboxItem) + .forEach(items::add); + } + if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "REPORT".equalsIgnoreCase(type))) { + skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(page, size)).getContent().stream() + .map(this::toReportInboxItem) + .forEach(items::add); + } + items.sort(Comparator.comparing( + GovernanceInboxItemResponse::timestamp, + Comparator.nullsLast(String::compareTo) + ).reversed()); + int fromIndex = Math.min(page * size, items.size()); + int toIndex = Math.min(fromIndex + size, items.size()); + return new PageResponse<>(items.subList(fromIndex, toIndex), items.size(), page, size); + } + + public PageResponse listActivity(Set platformRoles, int page, int size) { + if (!canReadActivity(platformRoles)) { + return new PageResponse<>(List.of(), 0, page, size); + } + PageResponse raw = adminAuditLogAppService.listAuditLogsByActions( + page, + size, + null, + ACTIVITY_ACTIONS, + null, + null, + null, + null, + null, + null + ); + List items = raw.items().stream() + .map(item -> new GovernanceActivityItemResponse( + item.id(), + item.action(), + item.userId(), + item.username(), + item.resourceType(), + item.resourceId(), + item.details(), + item.timestamp() != null ? item.timestamp().toString() : null + )) + .toList(); + return new PageResponse<>(items, items.size(), page, size); + } + + private Page visiblePendingReviews(Map namespaceRoles, + Set platformRoles, + int size) { + if (hasPlatformGovernanceRole(platformRoles)) { + return reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, size)); + } + List tasks = namespaceRoles.entrySet().stream() + .filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN) + .map(entry -> reviewTaskRepository.findByNamespaceIdAndStatus(entry.getKey(), ReviewTaskStatus.PENDING, PageRequest.of(0, size))) + .flatMap(pageResult -> pageResult.getContent().stream()) + .toList(); + return new org.springframework.data.domain.PageImpl<>(tasks, PageRequest.of(0, size), tasks.size()); + } + + private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task) { + SkillVersion version = skillVersionRepository.findById(task.getSkillVersionId()).orElse(null); + Skill skill = version != null ? skillRepository.findById(version.getSkillId()).orElse(null) : null; + Namespace namespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null; + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + String versionName = version != null ? version.getVersion() : null; + return new GovernanceInboxItemResponse( + "REVIEW", + task.getId(), + join(namespaceSlug, skillSlug, versionName), + "Pending review", + task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null, + namespaceSlug, + skillSlug + ); + } + + private GovernanceInboxItemResponse toPromotionInboxItem(PromotionRequest request) { + Skill skill = skillRepository.findById(request.getSourceSkillId()).orElse(null); + SkillVersion version = skillVersionRepository.findById(request.getSourceVersionId()).orElse(null); + Namespace sourceNamespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null; + Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()).orElse(null); + String sourceSlug = sourceNamespace != null ? sourceNamespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + String targetSlug = targetNamespace != null ? targetNamespace.getSlug() : null; + String versionName = version != null ? version.getVersion() : null; + return new GovernanceInboxItemResponse( + "PROMOTION", + request.getId(), + join(sourceSlug, skillSlug, versionName), + targetSlug != null ? "Promote to @" + targetSlug : "Pending promotion", + request.getSubmittedAt() != null ? request.getSubmittedAt().toString() : null, + sourceSlug, + skillSlug + ); + } + + private GovernanceInboxItemResponse toReportInboxItem(SkillReport report) { + Skill skill = skillRepository.findById(report.getSkillId()).orElse(null); + Namespace namespace = namespaceRepository.findById(report.getNamespaceId()).orElse(null); + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + return new GovernanceInboxItemResponse( + "REPORT", + report.getId(), + join(namespaceSlug, skillSlug, null), + report.getReason(), + report.getCreatedAt() != null ? report.getCreatedAt().toString() : null, + namespaceSlug, + skillSlug + ); + } + + private String join(String namespaceSlug, String skillSlug, String version) { + String path = namespaceSlug != null && skillSlug != null ? namespaceSlug + "/" + skillSlug : "Unknown target"; + return version != null ? path + "@" + version : path; + } + + private boolean hasPlatformGovernanceRole(Set platformRoles) { + return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); + } + + private boolean canReadActivity(Set platformRoles) { + return hasPlatformGovernanceRole(platformRoles) + || platformRoles.contains("AUDITOR"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index b262621f..00183e97 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,14 +1,24 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; 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.service.SkillLifecycleProjectionService; +import com.iflytek.skillhub.domain.social.SkillStarRepository; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,73 +27,142 @@ @Service public class MySkillAppService { - private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillStarRepository skillStarRepository; + private final PromotionRequestRepository promotionRequestRepository; + private final SkillLifecycleProjectionService skillLifecycleProjectionService; public MySkillAppService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + SkillVersionRepository skillVersionRepository, + SkillStarRepository skillStarRepository, + PromotionRequestRepository promotionRequestRepository, + SkillLifecycleProjectionService skillLifecycleProjectionService) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; + this.skillStarRepository = skillStarRepository; + this.promotionRequestRepository = promotionRequestRepository; + this.skillLifecycleProjectionService = skillLifecycleProjectionService; } - public List listMySkills(String userId) { - List skills = skillRepository.findByOwnerId(userId).stream() - .sorted(Comparator.comparing(Skill::getUpdatedAt).reversed()) + public PageResponse listMySkills(String userId, int page, int size) { + Page skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); + List skills = skillPage.getContent(); + + List namespaceIds = skills.stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespacesById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); + + List items = skills.stream() + .map(skill -> toSummaryResponse(skill, userId, namespacesById)) .toList(); - List latestVersionIds = skills.stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) + return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); + } + + public PageResponse listMyStars(String userId, int page, int size) { + Page starPage = skillStarRepository.findByUserId( + userId, + PageRequest.of(page, size) + ); + List stars = starPage.getContent(); + + List skillIds = stars.stream() + .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) .distinct() .toList(); - Map versionsById = latestVersionIds.isEmpty() + Map skillsById = skillIds.isEmpty() ? Map.of() - : skillVersionRepository.findByIdIn(latestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + : skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); - List namespaceIds = skills.stream() + List namespaceIds = skillsById.values().stream() .map(Skill::getNamespaceId) .distinct() .toList(); - Map namespaceSlugsById = namespaceIds.isEmpty() + Map namespacesById = namespaceIds.isEmpty() ? Map.of() : namespaceRepository.findByIdIn(namespaceIds).stream() - .collect(Collectors.toMap( - com.iflytek.skillhub.domain.namespace.Namespace::getId, - com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); - return skills.stream() - .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + List items = stars.stream() + .map(star -> skillsById.get(star.getSkillId())) + .filter(java.util.Objects::nonNull) + .map(skill -> toSummaryResponse(skill, userId, namespacesById)) .toList(); + + return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize()); } private SkillSummaryResponse toSummaryResponse( Skill skill, - Map versionsById, - Map namespaceSlugsById) { - String latestVersion = skill.getLatestVersionId() == null - ? null - : Optional.ofNullable(versionsById.get(skill.getLatestVersionId())) - .map(SkillVersion::getVersion) - .orElse(null); + String currentUserId, + Map namespacesById) { + com.iflytek.skillhub.domain.namespace.Namespace namespace = namespacesById.get(skill.getNamespaceId()); + SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer( + skill, + currentUserId, + Map.of() + ); + SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion(); + SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion(); + SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion(); return new SkillSummaryResponse( skill.getId(), skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), skill.getRatingAvg(), skill.getRatingCount(), - latestVersion, - namespaceSlugsById.get(skill.getNamespaceId()), - skill.getUpdatedAt() + namespace != null ? namespace.getSlug() : null, + skill.getUpdatedAt(), + canSubmitPromotion(skill, publishedVersion, namespace), + toLifecycleVersion(headlineVersion), + toLifecycleVersion(publishedVersion), + toLifecycleVersion(ownerPreviewVersion), + projection.resolutionMode().name() ); } + + private boolean canSubmitPromotion( + Skill skill, + SkillLifecycleProjectionService.VersionProjection publishedVersion, + com.iflytek.skillhub.domain.namespace.Namespace namespace) { + if (namespace == null) { + return false; + } + if (namespace.getType() == NamespaceType.GLOBAL) { + return false; + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) { + return false; + } + return publishedVersion != null && "PUBLISHED".equals(publishedVersion.status()); + } + + private SkillLifecycleVersionResponse toLifecycleVersion(SkillLifecycleProjectionService.VersionProjection projection) { + if (projection == null) { + return null; + } + return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java new file mode 100644 index 00000000..0401a7c8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class NamespaceMemberCandidateService { + + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 20; + + private final NamespaceService namespaceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + + public NamespaceMemberCandidateService(NamespaceService namespaceService, + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { + this.namespaceService = namespaceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + } + + @Transactional(readOnly = true) + public List searchCandidates(String slug, String search, String operatorUserId, int size) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + namespaceService.assertAdminOrOwner(namespace.getId(), operatorUserId); + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + + String keyword = normalizeSearch(search); + if (keyword == null) { + return List.of(); + } + + int pageSize = normalizeSize(size); + Set existingMemberIds = namespaceMemberRepository.findByNamespaceId(namespace.getId(), PageRequest.of(0, 500)) + .stream() + .map(NamespaceMember::getUserId) + .collect(Collectors.toSet()); + + return userAccountRepository.search(keyword, UserStatus.ACTIVE, PageRequest.of(0, pageSize)).stream() + .filter(user -> !existingMemberIds.contains(user.getId())) + .map(NamespaceCandidateUserResponse::from) + .toList(); + } + + private String normalizeSearch(String search) { + if (!StringUtils.hasText(search)) { + return null; + } + String keyword = search.trim(); + if (keyword.length() < 2) { + throw new DomainBadRequestException("error.namespace.member.search.tooShort"); + } + return keyword; + } + + private int normalizeSize(int size) { + if (size <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(size, MAX_LIMIT); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java new file mode 100644 index 00000000..f24606fa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.exception.BadRequestException; +import com.iflytek.skillhub.exception.ForbiddenException; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.springframework.stereotype.Service; + +@Service +public class SessionBootstrapService { + + private final AuthSessionBootstrapProperties properties; + private final Map authenticatorsByProvider; + private final PlatformSessionService platformSessionService; + + public SessionBootstrapService(AuthSessionBootstrapProperties properties, + List authenticators, + PlatformSessionService platformSessionService) { + this.properties = properties; + this.authenticatorsByProvider = authenticators.stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + PassiveSessionAuthenticator::providerCode, + Function.identity() + )); + this.platformSessionService = platformSessionService; + } + + public PlatformPrincipal bootstrap(String providerCode, HttpServletRequest request) { + if (!properties.isEnabled()) { + throw new ForbiddenException("error.auth.sessionBootstrap.disabled"); + } + + PassiveSessionAuthenticator authenticator = authenticatorsByProvider.get(providerCode); + if (authenticator == null) { + throw new BadRequestException("error.auth.sessionBootstrap.providerUnsupported", providerCode); + } + + PlatformPrincipal principal = authenticator.authenticate(request) + .orElseThrow(() -> new UnauthorizedException("error.auth.sessionBootstrap.notAuthenticated")); + platformSessionService.establishSession(principal, request); + return principal; + } + + public void establishSession(PlatformPrincipal principal, HttpServletRequest request) { + platformSessionService.establishSession(principal, request); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 3a316f7b..13a01261 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -1,11 +1,14 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.search.SearchQuery; @@ -26,17 +29,23 @@ public class SkillSearchAppService { private final SearchQueryService searchQueryService; private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; - private final SkillVersionRepository skillVersionRepository; + private final NamespaceService namespaceService; + private final VisibilityChecker visibilityChecker; + private final SkillLifecycleProjectionService skillLifecycleProjectionService; public SkillSearchAppService( SearchQueryService searchQueryService, SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + NamespaceService namespaceService, + VisibilityChecker visibilityChecker, + SkillLifecycleProjectionService skillLifecycleProjectionService) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; - this.skillVersionRepository = skillVersionRepository; + this.namespaceService = namespaceService; + this.visibilityChecker = visibilityChecker; + this.skillLifecycleProjectionService = skillLifecycleProjectionService; } public record SearchResponse( @@ -55,62 +64,18 @@ public SearchResponse search( String userId, Map userNsRoles) { - Long namespaceId = resolveNamespaceId(namespaceSlug); + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - SearchQuery query = new SearchQuery( - keyword, - namespaceId, - scope, - sortBy != null ? sortBy : "newest", - page, - size - ); - - SearchResult result = searchQueryService.search(query); - List matchedSkills = result.skillIds().isEmpty() - ? List.of() - : skillRepository.findByIdIn(result.skillIds()); - Map skillsById = matchedSkills.stream() - .collect(Collectors.toMap(Skill::getId, Function.identity())); - - List latestVersionIds = matchedSkills.stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) - .distinct() - .toList(); - Map versionsById = latestVersionIds.isEmpty() - ? Map.of() - : skillVersionRepository.findByIdIn(latestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); - - List namespaceIds = matchedSkills.stream() - .map(Skill::getNamespaceId) - .distinct() - .toList(); - Map namespaceSlugsById = namespaceIds.isEmpty() - ? Map.of() - : namespaceRepository.findByIdIn(namespaceIds).stream() - .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, - com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); - - List skills = result.skillIds().stream() - .map(skillsById::get) - .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) - .toList(); - - return new SearchResponse(skills, result.total(), result.page(), result.size()); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, userId, userNsRoles, scope); } - private Long resolveNamespaceId(String namespaceSlug) { + private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { if (namespaceSlug == null || namespaceSlug.isBlank()) { return null; } - return namespaceRepository.findBySlug(namespaceSlug) - .map(com.iflytek.skillhub.domain.namespace.Namespace::getId) - .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); + return namespaceService.getNamespaceBySlugForRead(namespaceSlug, userId, userNsRoles != null ? userNsRoles : Map.of()).getId(); } private SearchVisibilityScope buildVisibilityScope(String userId, Map userNsRoles) { @@ -131,15 +96,88 @@ private SearchVisibilityScope buildVisibilityScope(String userId, Map userNsRoles, + SearchVisibilityScope scope) { + int batchSize = Math.max(size, 20); + long rawTotal = Long.MAX_VALUE; + int rawPage = 0; + long visibleSeen = 0; + int visibleStart = page * size; + List pageItems = new java.util.ArrayList<>(); + + while ((long) rawPage * batchSize < rawTotal) { + SearchResult result = searchQueryService.search(new SearchQuery( + keyword, + namespaceId, + scope, + sortBy, + rawPage, + batchSize + )); + rawTotal = result.total(); + List visibleBatch = mapVisibleSkillSummaries(result.skillIds(), userId, userNsRoles); + for (SkillSummaryResponse item : visibleBatch) { + if (visibleSeen >= visibleStart && pageItems.size() < size) { + pageItems.add(item); + } + visibleSeen++; + } + if (result.skillIds().isEmpty()) { + break; + } + rawPage++; + } + + return new SearchResponse(pageItems, visibleSeen, page, size); + } + + private List mapVisibleSkillSummaries( + List skillIds, + String userId, + Map userNsRoles) { + if (skillIds.isEmpty()) { + return List.of(); + } + + List matchedSkills = skillRepository.findByIdIn(skillIds); + Map skillsById = matchedSkills.stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List namespaceIds = matchedSkills.stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespacesById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + Map namespaceSlugsById = namespacesById.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug())); + + return skillIds.stream() + .map(skillsById::get) + .filter(java.util.Objects::nonNull) + .filter(skill -> visibilityChecker.canAccess(skill, userId, userNsRoles != null ? userNsRoles : Map.of())) + .filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles)) + .map(skill -> toSummaryResponse(skill, namespaceSlugsById)) + .toList(); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, - Map versionsById, Map namespaceSlugsById) { - String latestVersion = skill.getLatestVersionId() == null - ? null - : java.util.Optional.ofNullable(versionsById.get(skill.getLatestVersionId())) - .map(SkillVersion::getVersion) - .orElse(null); + SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer( + skill, + null, + Map.of() + ); String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId()); return new SkillSummaryResponse( @@ -147,13 +185,44 @@ private SkillSummaryResponse toSummaryResponse( skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), skill.getRatingAvg(), skill.getRatingCount(), - latestVersion, namespaceSlug, - skill.getUpdatedAt() + skill.getUpdatedAt(), + false, + toLifecycleVersion(projection.headlineVersion()), + toLifecycleVersion(projection.publishedVersion()), + toLifecycleVersion(projection.ownerPreviewVersion()), + projection.resolutionMode().name() ); } + + private com.iflytek.skillhub.dto.SkillLifecycleVersionResponse toLifecycleVersion( + SkillLifecycleProjectionService.VersionProjection projection) { + if (projection == null) { + return null; + } + return new com.iflytek.skillhub.dto.SkillLifecycleVersionResponse( + projection.id(), + projection.version(), + projection.status() + ); + } + + private boolean namespaceVisible( + Long namespaceId, + Map namespacesById, + String userId, + Map userNsRoles) { + NamespaceStatus status = java.util.Optional.ofNullable(namespacesById.get(namespaceId)) + .map(Namespace::getStatus) + .orElse(NamespaceStatus.ACTIVE); + if (status != NamespaceStatus.ARCHIVED) { + return true; + } + return userId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); + } } 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 b70b0028..935ad253 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 @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; import java.time.Instant; @Component @@ -16,15 +17,17 @@ public class IdempotencyCleanupTask { private static final long STALE_THRESHOLD_MINUTES = 30; private final IdempotencyRecordRepository idempotencyRecordRepository; + private final Clock clock; - public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository) { + public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository, Clock clock) { this.idempotencyRecordRepository = idempotencyRecordRepository; + this.clock = clock; } @Scheduled(cron = "0 0 2 * * ?") @Transactional public void cleanupExpiredRecords() { - Instant now = Instant.now(); + Instant now = Instant.now(clock); int deleted = idempotencyRecordRepository.deleteExpired(now); logger.info("Cleaned up {} expired idempotency records", deleted); } @@ -32,7 +35,7 @@ public void cleanupExpiredRecords() { @Scheduled(fixedDelay = 300000) @Transactional public void cleanupStaleProcessing() { - Instant threshold = Instant.now().minusSeconds(STALE_THRESHOLD_MINUTES * 60); + Instant threshold = Instant.now(clock).minusSeconds(STALE_THRESHOLD_MINUTES * 60); int updated = idempotencyRecordRepository.markStaleAsFailed(threshold); if (updated > 0) { logger.info("Marked {} stale processing records as failed", updated); diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index f79eb483..705cedd1 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -23,6 +23,11 @@ spring: client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} +skillhub: + auth: + mock: + enabled: true + logging: level: com.iflytek.skillhub: DEBUG diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a9e25284..03c6fcaa 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -1,6 +1,14 @@ server: port: 8080 shutdown: graceful + forward-headers-strategy: framework + servlet: + session: + timeout: ${SERVER_SERVLET_SESSION_TIMEOUT:8h} + cookie: + http-only: true + secure: ${SESSION_COOKIE_SECURE:false} + same-site: lax spring: messages: @@ -16,23 +24,28 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + time_zone: UTC + jackson: + time-zone: UTC flyway: enabled: true locations: classpath:db/migration datasource: - url: jdbc:postgresql://localhost:5432/skillhub - username: skillhub - password: skillhub_dev + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/skillhub} + username: ${SPRING_DATASOURCE_USERNAME:skillhub} + password: ${SPRING_DATASOURCE_PASSWORD:skillhub_dev} hikari: - maximum-pool-size: 10 + maximum-pool-size: ${DB_POOL_MAX_SIZE:10} data: redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} + host: ${SPRING_DATA_REDIS_HOST:${REDIS_HOST:localhost}} + port: ${SPRING_DATA_REDIS_PORT:${REDIS_PORT:6379}} + password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}} session: store-type: redis redis: - namespace: skillhub:session + namespace: ${SESSION_REDIS_NAMESPACE:skillhub:session} security: oauth2: client: @@ -50,39 +63,75 @@ spring: max-request-size: 100MB skillhub: + auth: + mock: + enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} + direct: + enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} + session-bootstrap: + enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false} + public: + base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: mode: OPEN storage: - type: local + provider: ${SKILLHUB_STORAGE_PROVIDER:local} local: base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage} + s3: + endpoint: ${SKILLHUB_STORAGE_S3_ENDPOINT:} + public-endpoint: ${SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT:} + bucket: ${SKILLHUB_STORAGE_S3_BUCKET:skillhub} + access-key: ${SKILLHUB_STORAGE_S3_ACCESS_KEY:} + secret-key: ${SKILLHUB_STORAGE_S3_SECRET_KEY:} + region: ${SKILLHUB_STORAGE_S3_REGION:us-east-1} + force-path-style: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:true} + auto-create-bucket: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:false} + presign-expiry: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:PT10M} + max-connections: ${SKILLHUB_STORAGE_S3_MAX_CONNECTIONS:100} + connection-acquisition-timeout: ${SKILLHUB_STORAGE_S3_CONNECTION_ACQUISITION_TIMEOUT:PT2S} + api-call-attempt-timeout: ${SKILLHUB_STORAGE_S3_API_CALL_ATTEMPT_TIMEOUT:PT10S} + api-call-timeout: ${SKILLHUB_STORAGE_S3_API_CALL_TIMEOUT:PT30S} search: engine: postgres rebuild-on-startup: false + semantic: + enabled: true + weight: 0.35 + candidate-multiplier: 8 + max-candidates: 120 + ratelimit: + download: + anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl} + anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D} + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production} publish: + max-file-count: 100 + max-single-file-size: 10485760 # 10MB max-package-size: 104857600 # 100MB - allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh + allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.html,.css,.csv,.pdf,.toml,.xml,.ini,.cfg,.env,.js,.ts,.py,.sh,.rb,.go,.rs,.java,.kt,.lua,.sql,.r,.bat,.ps1,.zsh,.bash,.png,.jpg,.jpeg,.svg,.gif,.webp,.ico + device-auth: + verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} + bootstrap: + admin: + enabled: ${BOOTSTRAP_ADMIN_ENABLED:false} + user-id: ${BOOTSTRAP_ADMIN_USER_ID:docker-admin} + username: ${BOOTSTRAP_ADMIN_USERNAME:admin} + password: ${BOOTSTRAP_ADMIN_PASSWORD:ChangeMe!2026} + display-name: ${BOOTSTRAP_ADMIN_DISPLAY_NAME:Admin} + email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local} management: endpoints: web: exposure: - include: health,info + include: health,info,prometheus,metrics endpoint: health: show-details: when-authorized - ---- -# Docker profile -spring: - config: - activate: - on-profile: docker - datasource: - url: jdbc:postgresql://${DB_HOST:postgres}:${DB_PORT:5432}/${DB_NAME:skillhub} - username: ${DB_USER:skillhub} - password: ${DB_PASS:skillhub_dev} - data: - redis: - host: ${REDIS_HOST:redis} - port: ${REDIS_PORT:6379} + metrics: + tags: + application: skillhub + export: + prometheus: + enabled: true diff --git a/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql new file mode 100644 index 00000000..c93da0aa --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql @@ -0,0 +1,16 @@ +CREATE TABLE skill_report ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE, + namespace_id BIGINT NOT NULL REFERENCES namespace(id) ON DELETE CASCADE, + reporter_id VARCHAR(128) NOT NULL, + reason VARCHAR(200) NOT NULL, + details TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + handled_by VARCHAR(128), + handle_comment TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + handled_at TIMESTAMP +); + +CREATE INDEX idx_skill_report_status_created_at ON skill_report(status, created_at DESC); +CREATE INDEX idx_skill_report_skill_id ON skill_report(skill_id); diff --git a/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql b/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql new file mode 100644 index 00000000..ddce45d4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql @@ -0,0 +1,2 @@ +ALTER TABLE skill_search_document +ADD COLUMN semantic_vector TEXT; diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql b/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql new file mode 100644 index 00000000..9a9c7368 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql @@ -0,0 +1,15 @@ +CREATE TABLE user_notification ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + entity_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + body_json TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'UNREAD', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + read_at TIMESTAMPTZ +); + +CREATE INDEX idx_user_notification_user_created_at ON user_notification(user_id, created_at DESC); +CREATE INDEX idx_user_notification_user_status ON user_notification(user_id, status, created_at DESC); diff --git a/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql new file mode 100644 index 00000000..22c43662 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql @@ -0,0 +1,5 @@ +ALTER TABLE api_token + ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC', + ALTER COLUMN last_used_at TYPE TIMESTAMPTZ USING last_used_at AT TIME ZONE 'UTC', + ALTER COLUMN revoked_at TYPE TIMESTAMPTZ USING revoked_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql b/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql new file mode 100644 index 00000000..f1fac58b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql @@ -0,0 +1,6 @@ +-- V12__skill_owner_uniqueness.sql +-- Change skill uniqueness from (namespace_id, slug) to (namespace_id, slug, owner_id) +-- to support owner-isolated skill records with the same name + +ALTER TABLE skill DROP CONSTRAINT skill_namespace_id_slug_key; +ALTER TABLE skill ADD CONSTRAINT skill_namespace_id_slug_owner_id_key UNIQUE(namespace_id, slug, owner_id); diff --git a/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql new file mode 100644 index 00000000..d70730cb --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql @@ -0,0 +1,4 @@ +ALTER TABLE account_merge_request + ALTER COLUMN token_expires_at TYPE TIMESTAMPTZ USING token_expires_at AT TIME ZONE 'UTC', + ALTER COLUMN completed_at TYPE TIMESTAMPTZ USING completed_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V14__add_skill_version_stats.sql b/server/skillhub-app/src/main/resources/db/migration/V14__add_skill_version_stats.sql new file mode 100644 index 00000000..67a67c58 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V14__add_skill_version_stats.sql @@ -0,0 +1,8 @@ +CREATE TABLE skill_version_stats ( + skill_version_id BIGINT PRIMARY KEY REFERENCES skill_version(id) ON DELETE CASCADE, + skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE, + download_count BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_skill_version_stats_skill_id ON skill_version_stats(skill_id); diff --git a/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_download_state.sql b/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_download_state.sql new file mode 100644 index 00000000..0384d407 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_download_state.sql @@ -0,0 +1,10 @@ +ALTER TABLE skill_version + ADD COLUMN bundle_ready BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN download_ready BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE skill_version +SET download_ready = CASE + WHEN status = 'PUBLISHED' AND file_count > 0 THEN TRUE + ELSE FALSE + END, + bundle_ready = FALSE; diff --git a/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql new file mode 100644 index 00000000..db3e68dd --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql @@ -0,0 +1,4 @@ +ALTER TABLE skill_version + ALTER COLUMN published_at TYPE TIMESTAMPTZ USING published_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN yanked_at TYPE TIMESTAMPTZ USING yanked_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V16__skill_hidden_at_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V16__skill_hidden_at_timestamptz.sql new file mode 100644 index 00000000..f71193ec --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V16__skill_hidden_at_timestamptz.sql @@ -0,0 +1,2 @@ +ALTER TABLE skill + ALTER COLUMN hidden_at TYPE TIMESTAMPTZ USING hidden_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V17__skill_created_updated_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V17__skill_created_updated_timestamptz.sql new file mode 100644 index 00000000..b7db4491 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V17__skill_created_updated_timestamptz.sql @@ -0,0 +1,3 @@ +ALTER TABLE skill + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V18__namespace_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V18__namespace_timestamptz.sql new file mode 100644 index 00000000..f1e26789 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V18__namespace_timestamptz.sql @@ -0,0 +1,7 @@ +ALTER TABLE namespace + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE namespace_member + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V19__skill_secondary_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V19__skill_secondary_timestamptz.sql new file mode 100644 index 00000000..33058ae7 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V19__skill_secondary_timestamptz.sql @@ -0,0 +1,9 @@ +ALTER TABLE skill_tag + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_file + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_version_stats + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V20__social_and_skill_report_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V20__social_and_skill_report_timestamptz.sql new file mode 100644 index 00000000..de60f745 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V20__social_and_skill_report_timestamptz.sql @@ -0,0 +1,10 @@ +ALTER TABLE skill_star + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_rating + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_report + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN handled_at TYPE TIMESTAMPTZ USING handled_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V21__user_account_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V21__user_account_timestamptz.sql new file mode 100644 index 00000000..1ac3b486 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V21__user_account_timestamptz.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_account + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V22__auth_supporting_tables_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V22__auth_supporting_tables_timestamptz.sql new file mode 100644 index 00000000..2cffad08 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V22__auth_supporting_tables_timestamptz.sql @@ -0,0 +1,14 @@ +ALTER TABLE identity_binding + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE role + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE user_role_binding + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE local_credential + ALTER COLUMN locked_until TYPE TIMESTAMPTZ USING locked_until AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V23__review_and_idempotency_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V23__review_and_idempotency_timestamptz.sql new file mode 100644 index 00000000..3ea39b11 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V23__review_and_idempotency_timestamptz.sql @@ -0,0 +1,11 @@ +ALTER TABLE review_task + ALTER COLUMN submitted_at TYPE TIMESTAMPTZ USING submitted_at AT TIME ZONE 'UTC', + ALTER COLUMN reviewed_at TYPE TIMESTAMPTZ USING reviewed_at AT TIME ZONE 'UTC'; + +ALTER TABLE promotion_request + ALTER COLUMN submitted_at TYPE TIMESTAMPTZ USING submitted_at AT TIME ZONE 'UTC', + ALTER COLUMN reviewed_at TYPE TIMESTAMPTZ USING reviewed_at AT TIME ZONE 'UTC'; + +ALTER TABLE idempotency_record + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql new file mode 100644 index 00000000..4e746456 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql @@ -0,0 +1,32 @@ +CREATE TABLE local_credential ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_local_credential_username ON local_credential (username); +CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential (user_id); + +CREATE TABLE account_merge_request ( + id BIGSERIAL PRIMARY KEY, + primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + verification_token VARCHAR(255), + token_expires_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_merge_primary_status ON account_merge_request (primary_user_id, status); +CREATE UNIQUE INDEX idx_merge_secondary_pending + ON account_merge_request (secondary_user_id) + WHERE status = 'PENDING'; +CREATE INDEX idx_merge_token_pending + ON account_merge_request (verification_token) + WHERE status = 'PENDING'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql b/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql new file mode 100644 index 00000000..6f0bde78 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql @@ -0,0 +1,11 @@ +ALTER TABLE skill ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE skill ADD COLUMN hidden_at TIMESTAMP; +ALTER TABLE skill ADD COLUMN hidden_by VARCHAR(128) REFERENCES user_account(id); + +ALTER TABLE skill_version ADD COLUMN yanked_at TIMESTAMP; +ALTER TABLE skill_version ADD COLUMN yanked_by VARCHAR(128) REFERENCES user_account(id); +ALTER TABLE skill_version ADD COLUMN yank_reason TEXT; + +CREATE INDEX idx_skill_hidden ON skill(hidden) WHERE hidden = TRUE; +CREATE INDEX idx_audit_log_actor_time ON audit_log(actor_user_id, created_at DESC); +CREATE INDEX idx_audit_log_action_time ON audit_log(action, created_at DESC); diff --git a/server/skillhub-app/src/main/resources/db/migration/V7__fix_audit_log_jsonb_type.sql b/server/skillhub-app/src/main/resources/db/migration/V7__fix_audit_log_jsonb_type.sql new file mode 100644 index 00000000..10fb3517 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V7__fix_audit_log_jsonb_type.sql @@ -0,0 +1,10 @@ +-- Fix audit_log detail_json column to properly handle JSONB type +-- This migration ensures existing data is compatible with the JSONB type + +-- The column is already defined as jsonb in V1, so this migration is a no-op +-- for fresh installations. For existing installations with text data, this would +-- have been needed, but since the column was always jsonb, we just verify it exists. + +-- No-op migration: column is already jsonb in V1 +-- This file exists to maintain migration version continuity +SELECT 1; diff --git a/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql new file mode 100644 index 00000000..2038bc4c --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql @@ -0,0 +1,6 @@ +ALTER TABLE api_token + ALTER COLUMN name TYPE VARCHAR(64); + +CREATE UNIQUE INDEX uk_api_token_user_active_name + ON api_token (user_id, LOWER(name)) + WHERE revoked_at IS NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V9__expand_skill_summary_storage.sql b/server/skillhub-app/src/main/resources/db/migration/V9__expand_skill_summary_storage.sql new file mode 100644 index 00000000..66be479a --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V9__expand_skill_summary_storage.sql @@ -0,0 +1,21 @@ +ALTER TABLE skill + ALTER COLUMN summary TYPE TEXT; + +DROP INDEX IF EXISTS idx_search_vector; + +ALTER TABLE skill_search_document + DROP COLUMN search_vector; + +ALTER TABLE skill_search_document + ALTER COLUMN summary TYPE TEXT; + +ALTER TABLE skill_search_document +ADD COLUMN search_vector tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(summary, '')), 'B') || + setweight(to_tsvector('simple', coalesce(keywords, '')), 'B') || + setweight(to_tsvector('simple', coalesce(search_text, '')), 'C') +) STORED; + +CREATE INDEX idx_search_vector ON skill_search_document USING GIN (search_vector); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index c8b31e6b..6d099224 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -14,12 +14,41 @@ validation.namespace.displayName.size=Display name must not exceed 128 character validation.namespace.description.size=Description must not exceed 512 characters validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required +validation.auth.local.username.notBlank=Username cannot be blank +validation.auth.local.password.notBlank=Password cannot be blank +validation.auth.local.currentPassword.notBlank=Current password cannot be blank +validation.auth.local.newPassword.notBlank=New password cannot be blank +validation.auth.local.email.invalid=Email format is invalid validation.token.name.notBlank=Token name cannot be blank +validation.token.name.size=Token name must be at most 64 characters +validation.token.expiresAt.invalid=Expiration time format is invalid +validation.token.expiresAt.future=Expiration time must be in the future +error.token.name.duplicate=You already have a token with this name +error.token.notFound=Token not found: {0} error.auth.required=Authentication required +error.auth.local.username.exists=Username already exists +error.auth.local.email.exists=Email already exists +error.auth.local.password.tooShort=Password must be at least 8 characters +error.auth.local.password.tooLong=Password must not exceed 128 characters +error.auth.local.password.tooWeak=Password must include at least 3 character types +error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores +error.auth.local.invalidCredentials=Incorrect username or password +error.auth.local.notEnabled=Local account login is not enabled for this user +error.auth.local.accountDisabled=This account has been disabled +error.auth.local.accountPending=This account is pending activation +error.auth.local.accountMerged=This account has been merged and can no longer be used to log in +error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s) +error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s) +error.auth.direct.disabled=Direct authentication compatibility is disabled +error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0} +error.auth.sessionBootstrap.disabled=Session bootstrap is disabled +error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} +error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request error.forbidden=Forbidden error.rateLimit.exceeded=Rate limit exceeded +error.storage.unavailable=Object storage is temporarily unavailable. Please try again later. error.internal=An unexpected error occurred error.slug.blank=Slug cannot be blank error.slug.length=Slug length must be between {0} and {1} characters @@ -31,11 +60,13 @@ error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} error.namespace.membership.required=Namespace membership required error.namespace.admin.required=Namespace owner or admin role required +error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly error.namespace.member.alreadyExists=User is already a namespace member error.namespace.member.notFound=Member not found error.namespace.member.owner.remove=Cannot remove namespace owner error.namespace.member.owner.setDirect=Cannot set OWNER role directly, use ownership transfer instead +error.namespace.member.search.tooShort=Search keyword must be at least 2 characters error.namespace.owner.current.notFound=Current owner not found error.namespace.owner.current.invalid=Current user is not the namespace owner error.namespace.owner.new.notFound=New owner is not a namespace member @@ -50,12 +81,27 @@ error.skill.publish.publisher.notMember=Publisher is not a member of namespace: error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.skill.publish.archived=Archived skill must be restored before publishing: {0} +review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} +review.withdraw.not_submitter=Only the submitter can withdraw this review +review_task.not_found_for_version=No pending review submission found for version: {0} +error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters error.skill.notFound=Skill not found: {0} error.skill.access.denied=Access denied to skill: {0} error.skill.status.notActive=Skill is not active +error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can manage this skill error.skill.version.exists=Version already exists: {0} error.skill.version.notFound=Version not found: {0} error.skill.version.notPublished=Version is not published: {0} +error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0} +error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0} +error.skill.report.reason.required=Please provide a report reason +error.skill.report.unavailable=This skill cannot be reported right now: {0} +error.skill.report.self=You cannot report your own skill +error.skill.report.duplicate=You already have a pending report for this skill +error.skill.report.notFound=Skill report not found: {0} +error.skill.report.alreadyHandled=This skill report has already been handled +error.skill.report.status.invalid=Unsupported skill report status: {0} error.skill.version.latest.unavailable=No published version available for skill: {0} error.skill.version.latest.notFound=Latest published version not found error.skill.file.notFound=File not found: {0} @@ -76,3 +122,5 @@ error.admin.user.role.invalid=Invalid role: {0} error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here +error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace +error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 9480c9df..b98d4ad5 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -14,12 +14,41 @@ validation.namespace.displayName.size=显示名称长度不能超过 128 个字 validation.namespace.description.size=描述长度不能超过 512 个字符 validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 +validation.auth.local.username.notBlank=用户名不能为空 +validation.auth.local.password.notBlank=密码不能为空 +validation.auth.local.currentPassword.notBlank=当前密码不能为空 +validation.auth.local.newPassword.notBlank=新密码不能为空 +validation.auth.local.email.invalid=邮箱格式不正确 validation.token.name.notBlank=Token 名称不能为空 +validation.token.name.size=Token 名称最多 64 个字符 +validation.token.expiresAt.invalid=过期时间格式不正确 +validation.token.expiresAt.future=过期时间必须晚于当前时间 +error.token.name.duplicate=你已经有同名 Token +error.token.notFound=Token 不存在:{0} error.auth.required=需要先登录 +error.auth.local.username.exists=用户名已存在 +error.auth.local.email.exists=邮箱已存在 +error.auth.local.password.tooShort=密码至少需要 8 位 +error.auth.local.password.tooLong=密码长度不能超过 128 位 +error.auth.local.password.tooWeak=密码至少需要包含 3 种字符类型 +error.auth.local.username.invalid=用户名需为 3-64 位,且只能包含字母、数字或下划线 +error.auth.local.invalidCredentials=用户名或密码错误 +error.auth.local.notEnabled=当前用户未启用本地账号登录 +error.auth.local.accountDisabled=该账号已被禁用 +error.auth.local.accountPending=该账号尚未激活 +error.auth.local.accountMerged=该账号已合并,不能再用于登录 +error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试 +error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试 +error.auth.direct.disabled=直连认证兼容层未启用 +error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} +error.auth.sessionBootstrap.disabled=会话引导能力未启用 +error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} +error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 error.rateLimit.exceeded=请求过于频繁,请稍后再试 +error.storage.unavailable=对象存储暂时不可用,请稍后再试 error.internal=服务器内部错误 error.slug.blank=slug 不能为空 error.slug.length=slug 长度必须在 {0} 到 {1} 个字符之间 @@ -31,11 +60,13 @@ error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} error.namespace.membership.required=需要先加入该命名空间 error.namespace.admin.required=需要命名空间管理员或所有者权限 +error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色 error.namespace.member.alreadyExists=用户已经是该命名空间成员 error.namespace.member.notFound=未找到命名空间成员 error.namespace.member.owner.remove=不能移除命名空间 OWNER error.namespace.member.owner.setDirect=不能直接设置 OWNER 角色,请使用所有权转移 +error.namespace.member.search.tooShort=搜索关键词至少需要 2 个字符 error.namespace.owner.current.notFound=未找到当前所有者 error.namespace.owner.current.invalid=当前用户不是命名空间所有者 error.namespace.owner.new.notFound=新所有者不是该命名空间成员 @@ -50,12 +81,27 @@ error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0} error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.failed=预发布校验失败:{0} +error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} +review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} +review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 +review_task.not_found_for_version=未找到该版本对应的待审核记录:{0} +error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符 error.skill.notFound=未找到技能:{0} error.skill.access.denied=没有权限访问技能:{0} error.skill.status.notActive=技能未处于 ACTIVE 状态 +error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员可以管理该技能 error.skill.version.exists=版本已存在:{0} error.skill.version.notFound=未找到版本:{0} error.skill.version.notPublished=版本未发布:{0} +error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0} +error.skill.version.delete.lastVersion=无法删除最后一个版本:{0} +error.skill.report.reason.required=请填写举报原因 +error.skill.report.unavailable=当前无法举报该技能:{0} +error.skill.report.self=不能举报自己发布的技能 +error.skill.report.duplicate=你已经提交过该技能的待处理举报 +error.skill.report.notFound=未找到技能举报:{0} +error.skill.report.alreadyHandled=该技能举报已经处理过 +error.skill.report.status.invalid=不支持的技能举报状态:{0} error.skill.version.latest.unavailable=技能没有可下载的已发布版本:{0} error.skill.version.latest.notFound=未找到最新已发布版本 error.skill.file.notFound=未找到文件:{0} @@ -71,8 +117,10 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码 error.deviceAuth.deviceCode.expired=设备验证码已过期 error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.used=设备验证码已被使用 -error.admin.user.notFound=鐢ㄦ埛涓嶅瓨鍦細{0} -error.admin.user.role.invalid=鏃犳晥鐨勮鑹诧細{0} -error.admin.user.role.superAdmin.assignDenied=鍙湁 SUPER_ADMIN 鍙互鍒嗛厤 SUPER_ADMIN 瑙掕壊 -error.admin.user.status.invalid=鏃犳晥鐨勭敤鎴风姸鎬侊細{0} -error.admin.user.status.unsupported=杩欓噷鍙厑璁告寜 ACTIVE 鎴?DISABLED 绠$悊鐢ㄦ埛鐘舵€? +error.admin.user.notFound=用户不存在:{0} +error.admin.user.role.invalid=无效的角色:{0} +error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.status.invalid=无效的用户状态:{0} +error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 +error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 +error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java new file mode 100644 index 00000000..a151d2a8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java @@ -0,0 +1,139 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +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; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BootstrapAdminInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private LocalCredentialRepository localCredentialRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private PasswordEncoder passwordEncoder; + + private BootstrapAdminProperties bootstrapAdminProperties; + private BootstrapAdminInitializer initializer; + + @BeforeEach + void setUp() { + bootstrapAdminProperties = new BootstrapAdminProperties(); + initializer = new BootstrapAdminInitializer( + bootstrapAdminProperties, + userAccountRepository, + localCredentialRepository, + roleRepository, + userRoleBindingRepository, + namespaceRepository, + namespaceMemberRepository, + passwordEncoder + ); + } + + @Test + void shouldSeedBootstrapAdminWithCredentialRoleAndMembership() throws Exception { + bootstrapAdminProperties.setEnabled(true); + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(false); + when(userAccountRepository.findById("docker-admin")).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(passwordEncoder.encode("ChangeMe!2026")).thenReturn("encoded-password"); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId("docker-admin")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "docker-admin")).thenReturn(Optional.empty()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository, atLeastOnce()).save(userCaptor.capture()); + UserAccount savedUser = userCaptor.getAllValues().getLast(); + assertEquals("docker-admin", savedUser.getId()); + assertEquals("Admin", savedUser.getDisplayName()); + assertEquals("admin@skillhub.local", savedUser.getEmail()); + + ArgumentCaptor credentialCaptor = ArgumentCaptor.forClass(LocalCredential.class); + verify(localCredentialRepository).save(credentialCaptor.capture()); + assertEquals("docker-admin", credentialCaptor.getValue().getUserId()); + assertEquals("admin", credentialCaptor.getValue().getUsername()); + assertEquals("encoded-password", credentialCaptor.getValue().getPasswordHash()); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals("docker-admin", roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertEquals("docker-admin", memberCaptor.getValue().getUserId()); + assertEquals(NamespaceRole.OWNER, memberCaptor.getValue().getRole()); + } + + @Test + void shouldSkipWhenBootstrapAdminCredentialAlreadyExists() { + bootstrapAdminProperties.setEnabled(true); + when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(true); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(localCredentialRepository, never()).save(any(LocalCredential.class)); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void shouldSkipWhenBootstrapAdminIsDisabled() { + bootstrapAdminProperties.setEnabled(false); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(localCredentialRepository, never()).existsByUsernameIgnoreCase(any()); + verify(userAccountRepository, never()).save(any(UserAccount.class)); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java new file mode 100644 index 00000000..945530c6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.DefaultApplicationArguments; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocalDevDataInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + + private LocalDevDataInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new LocalDevDataInitializer( + userAccountRepository, + namespaceRepository, + namespaceMemberRepository, + roleRepository, + userRoleBindingRepository + ); + } + + @Test + void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty()); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository, times(2)).save(userCaptor.capture()); + List savedUsers = userCaptor.getAllValues(); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId()))); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId()))); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture()); + assertEquals( + List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID), + memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList() + ); + assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER)); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index e9c72230..c024cf08 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -3,6 +3,8 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; @@ -18,7 +20,7 @@ import java.util.List; import java.util.Set; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -43,6 +45,9 @@ class ClawHubCompatControllerTest { @MockBean private SkillSearchAppService skillSearchAppService; + @MockBean + private SkillQueryService skillQueryService; + @Test void search_returns_mapped_results() throws Exception { when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null)) @@ -52,54 +57,64 @@ void search_returns_mapped_results() throws Exception { "my-skill", "My Skill", "test summary", + "ACTIVE", 10L, 5, BigDecimal.valueOf(4.5), 2, - "1.2.0", "global", - LocalDateTime.of(2026, 3, 13, 9, 0))), + Instant.parse("2026-03-13T09:00:00Z"), + false, + new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), + null, + "PUBLISHED")), 1, 0, 20 )); - mockMvc.perform(get("/api/compat/v1/search") + mockMvc.perform(get("/api/v1/search") .param("q", "test")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.items").isArray()) - .andExpect(jsonPath("$.items[0].canonicalSlug").value("my-skill")) - .andExpect(jsonPath("$.items[0].description").value("test summary")) - .andExpect(jsonPath("$.items[0].latestVersion").value("1.2.0")) - .andExpect(jsonPath("$.items[0].starCount").value(5)); + .andExpect(jsonPath("$.results").isArray()) + .andExpect(jsonPath("$.results[0].slug").value("my-skill")) + .andExpect(jsonPath("$.results[0].summary").value("test summary")) + .andExpect(jsonPath("$.results[0].version").value("1.2.0")); } @Test void resolve_returns_correct_downloadUrl() throws Exception { - mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) + when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download")); + mockMvc.perform(get("/api/v1/resolve/my-skill")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) - .andExpect(jsonPath("$.version").value("latest")) - .andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download")); + .andExpect(jsonPath("$.match.version").value("latest")) + .andExpect(jsonPath("$.latestVersion.version").value("latest")); } @Test void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { - mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) + when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download")); + mockMvc.perform(get("/api/v1/resolve/team-ai--my-skill")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) - .andExpect(jsonPath("$.version").value("latest")) - .andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/team-ai/my-skill/download")); + .andExpect(jsonPath("$.match.version").value("latest")) + .andExpect(jsonPath("$.latestVersion.version").value("latest")); } @Test void resolve_with_version_returns_specified_version() throws Exception { - mockMvc.perform(get("/api/compat/v1/resolve/my-skill") + when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "global", "my-skill", "1.0.0", 2L, "sha", true, "/api/v1/skills/global/my-skill/download")); + mockMvc.perform(get("/api/v1/resolve/my-skill") .param("version", "1.0.0")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) - .andExpect(jsonPath("$.version").value("1.0.0")) - .andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download")); + .andExpect(jsonPath("$.match.version").value("1.0.0")) + .andExpect(jsonPath("$.latestVersion.version").value("1.0.0")); } @Test @@ -118,12 +133,12 @@ void whoami_with_auth_returns_user_info() throws Exception { List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) ); - mockMvc.perform(get("/api/compat/v1/whoami") + mockMvc.perform(get("/api/v1/whoami") .with(authentication(auth)) .with(csrf())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.userId").value("user-42")) - .andExpect(jsonPath("$.displayName").value("tester")) - .andExpect(jsonPath("$.email").value("tester@example.com")); + .andExpect(jsonPath("$.user.handle").value("user-42")) + .andExpect(jsonPath("$.user.displayName").value("tester")) + .andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java new file mode 100644 index 00000000..d3342b77 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ClawHubRegistryFacadeTest { + + @Test + void search_mapsInstantToEpochMillis() { + CanonicalSlugMapper canonicalSlugMapper = new CanonicalSlugMapper(); + SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class); + SkillQueryService skillQueryService = mock(SkillQueryService.class); + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + + ClawHubRegistryFacade facade = new ClawHubRegistryFacade( + canonicalSlugMapper, + skillSearchAppService, + skillQueryService, + skillRepository, + skillVersionRepository, + userAccountRepository + ); + + Instant updatedAt = Instant.parse("2026-03-18T09:00:00Z"); + when(skillSearchAppService.search("agent", null, "relevance", 0, 20, null, Map.of())) + .thenReturn(new SkillSearchAppService.SearchResponse( + List.of(new SkillSummaryResponse( + 1L, + "time-skill", + "Time Skill", + "summary", + "ACTIVE", + 12L, + 3, + BigDecimal.valueOf(4.5), + 2, + "global", + updatedAt, + false, + new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), + null, + "PUBLISHED" + )), + 1, + 0, + 20 + )); + + ClawHubRegistrySearchResponse result = facade.search("agent", 20, null, Map.of()); + + assertThat(result.results()).hasSize(1); + assertThat(result.results().get(0).updatedAt()) + .isEqualTo(updatedAt.toEpochMilli()); + } +} 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 eca4f21b..df14d907 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,6 +31,6 @@ class WellKnownControllerTest { void clawhubConfig_returns_apiBase() throws Exception { mockMvc.perform(get("/.well-known/clawhub.json")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.apiBase").value("/api/compat/v1")); + .andExpect(jsonPath("$.apiBase").value("/api/v1")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java new file mode 100644 index 00000000..e0d5677b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AccountMergeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountMergeService accountMergeService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void initiate_returnsVerificationToken() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + given(accountMergeService.initiate("usr_primary", "secondary")) + .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", Instant.parse("2026-03-12T22:30:00Z"))); + + mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"secondaryIdentifier":"secondary"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.mergeRequestId").value(1)) + .andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary")) + .andExpect(jsonPath("$.data.verificationToken").value("merge-token")) + .andExpect(jsonPath("$.data.expiresAt").value("2026-03-12T22:30:00Z")); + } + + @Test + void verify_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1,"verificationToken":"merge-token"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge verified")); + + verify(accountMergeService).verify("usr_primary", 1L, "merge-token"); + } + + @Test + void confirm_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge completed")); + + verify(accountMergeService).confirm("usr_primary", 1L); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index c13c75f3..29184539 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.security.AuthFailureThrottleService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -10,20 +14,39 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import static org.hamcrest.Matchers.hasItems; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.security.oauth2.client.registration.github.client-name=GitHub", + "spring.security.oauth2.client.registration.gitee.client-id=placeholder", + "spring.security.oauth2.client.registration.gitee.client-secret=placeholder", + "spring.security.oauth2.client.registration.gitee.provider=gitee", + "spring.security.oauth2.client.registration.gitee.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.gitee.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", + "spring.security.oauth2.client.registration.gitee.scope=user_info", + "spring.security.oauth2.client.registration.gitee.client-name=Gitee", + "spring.security.oauth2.client.provider.gitee.authorization-uri=https://gitee.com/oauth/authorize", + "spring.security.oauth2.client.provider.gitee.token-uri=https://gitee.com/oauth/token", + "spring.security.oauth2.client.provider.gitee.user-info-uri=https://gitee.com/api/v5/user", + "spring.security.oauth2.client.provider.gitee.user-name-attribute=id" +}) class AuthControllerTest { @Autowired @@ -32,15 +55,28 @@ class AuthControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private AuthFailureThrottleService authFailureThrottleService; + + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test void meShouldReturnCurrentPrincipal() throws Exception { given(namespaceMemberRepository.findByUserId("user-42")).willReturn(List.of()); + given(userAccountRepository.findById("user-42")) + .willReturn(java.util.Optional.of(new UserAccount("user-42", "tester", "tester@example.com", "https://example.com/avatar.png"))); + given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of()); PlatformPrincipal principal = new PlatformPrincipal( "user-42", @@ -59,12 +95,14 @@ void meShouldReturnCurrentPrincipal() throws Exception { mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(header().string("X-Frame-Options", "DENY")) + .andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin")) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.userId").value("user-42")) .andExpect(jsonPath("$.data.displayName").value("tester")) .andExpect(jsonPath("$.data.oauthProvider").value("github")) - .andExpect(jsonPath("$.data.platformRoles[0]").value("SUPER_ADMIN")) + .andExpect(jsonPath("$.data.platformRoles[0]").value("USER")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } @@ -74,10 +112,59 @@ void providersShouldExposeGithubLoginEntry() throws Exception { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) - .andExpect(jsonPath("$.data[0].id").value("github")) - .andExpect(jsonPath("$.data[0].authorizationUrl").value("/oauth2/authorization/github")) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee"))) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( + "/oauth2/authorization/github", + "/oauth2/authorization/gitee" + ))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void providersShouldAppendReturnToWhenRequested() throws Exception { + mockMvc.perform(get("/api/v1/auth/providers").param("returnTo", "/dashboard/publish")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( + "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish", + "/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish" + ))); + } + + @Test + void methodsShouldExposeStandardLoginCatalog() throws Exception { + mockMvc.perform(get("/api/v1/auth/methods").param("returnTo", "/dashboard/publish")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[*].id", hasItems("local-password", "oauth-github", "oauth-gitee"))) + .andExpect(jsonPath("$.data[?(@.id=='local-password')].methodType").value(hasItems("PASSWORD"))) + .andExpect(jsonPath("$.data[?(@.id=='oauth-github')].actionUrl") + .value(hasItems("/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish"))); + } + + @Test + void sessionBootstrapShouldBeForbiddenWhenFeatureIsDisabled() throws Exception { + mockMvc.perform(post("/api/v1/auth/session/bootstrap") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso"} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void directLoginShouldBeForbiddenWhenFeatureIsDisabled() throws Exception { + mockMvc.perform(post("/api/v1/auth/direct/login") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso","username":"alice","password":"secret"} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java new file mode 100644 index 00000000..6e2d936e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.ratelimit.RateLimiter; +import com.iflytek.skillhub.security.AuthFailureThrottleService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthRateLimitControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LocalAuthService localAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @MockBean + private RateLimiter rateLimiter; + + @MockBean + private AuthFailureThrottleService authFailureThrottleService; + + @Test + void localLoginShouldReturnTooManyRequestsWhenRateLimitIsExceeded() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(false); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"wrong"} + """)) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value(429)); + + verify(localAuthService, never()).login(anyString(), anyString()); + } + + @Test + void localLoginShouldRecordCredentialFailuresForBruteForceTracking() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(localAuthService.login("alice", "wrong")) + .willThrow(new AuthFlowException(org.springframework.http.HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials")); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"wrong"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + + verify(authFailureThrottleService).assertAllowed("local", "alice", "127.0.0.1"); + verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1"); + } + + @Test + void localLoginShouldResetIdentifierThrottleAfterSuccess() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(localAuthService.login("alice", "correct")).willReturn(new PlatformPrincipal( + "usr_1", + "alice", + "alice@example.com", + "", + "local", + java.util.Set.of("USER") + )); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"correct"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(authFailureThrottleService).resetIdentifier("local", "alice"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java index 58d721ba..f4a55910 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java @@ -8,22 +8,17 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.io.ByteArrayOutputStream; import java.util.List; import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -43,8 +38,9 @@ class CliControllerTest { @Test void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { - mockMvc.perform(get("/api/v1/cli/whoami")) - .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/whoami")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -66,159 +62,10 @@ void whoamiShouldReturnCurrentPrincipal() throws Exception { List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")) ); - mockMvc.perform(get("/api/v1/cli/whoami").with(authentication(auth))) + mockMvc.perform(get("/api/v1/whoami").with(authentication(auth))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) - .andExpect(jsonPath("$.data.userId").value("user-7")) - .andExpect(jsonPath("$.data.displayName").value("cli-user")) - .andExpect(jsonPath("$.data.authType").value("api_token")) - .andExpect(jsonPath("$.data.platformRoles[0]").value("SKILL_ADMIN")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.requestId").isNotEmpty()); - } - - @Test - void checkShouldReturnValidForValidPackage() throws Exception { - byte[] zipBytes = createValidSkillZip(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(true)) - .andExpect(jsonPath("$.data.errors").isEmpty()) - .andExpect(jsonPath("$.data.fileCount").value(2)) - .andExpect(jsonPath("$.data.totalSize").isNumber()); - } - - @Test - void checkShouldReturnInvalidForMissingSkillMd() throws Exception { - byte[] zipBytes = createInvalidSkillZip(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors").isNotEmpty()) - .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")); - } - - @Test - void checkShouldReturnInvalidForDisallowedExtension() throws Exception { - byte[] zipBytes = createZipWithDisallowedFile(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors").isNotEmpty()); - } - - @Test - void checkShouldReturnInvalidForPathTraversalEntry() throws Exception { - byte[] zipBytes = createZipWithUnsafePath(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors[0]").value(org.hamcrest.Matchers.containsString("escapes package root"))) - .andExpect(jsonPath("$.data.fileCount").value(0)) - .andExpect(jsonPath("$.data.totalSize").value(0)); - } - - private byte[] createValidSkillZip() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - # Test Skill - This is a test skill. - """; - ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); - zos.putNextEntry(skillMdEntry); - zos.write(skillMdContent.getBytes()); - zos.closeEntry(); - - ZipEntry readmeEntry = new ZipEntry("README.md"); - zos.putNextEntry(readmeEntry); - zos.write("# README\nThis is a readme.".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createInvalidSkillZip() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - ZipEntry readmeEntry = new ZipEntry("README.md"); - zos.putNextEntry(readmeEntry); - zos.write("# README".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createZipWithDisallowedFile() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - # Test Skill - """; - ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); - zos.putNextEntry(skillMdEntry); - zos.write(skillMdContent.getBytes()); - zos.closeEntry(); - - ZipEntry exeEntry = new ZipEntry("malware.exe"); - zos.putNextEntry(exeEntry); - zos.write("bad content".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createZipWithUnsafePath() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - ZipEntry unsafeEntry = new ZipEntry("../secrets.txt"); - zos.putNextEntry(unsafeEntry); - zos.write("hidden".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); + .andExpect(jsonPath("$.user.handle").value("user-7")) + .andExpect(jsonPath("$.user.displayName").value("cli-user")) + .andExpect(jsonPath("$.user.image").value("")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java deleted file mode 100644 index f1ede80c..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.iflytek.skillhub.controller; - -import com.iflytek.skillhub.auth.device.DeviceAuthService; -import com.iflytek.skillhub.auth.device.DeviceCodeResponse; -import com.iflytek.skillhub.auth.device.DeviceTokenResponse; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -class DeviceAuthControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private DeviceAuthService deviceAuthService; - - @MockBean - private NamespaceMemberRepository namespaceMemberRepository; - - @Test - void requestDeviceCode_returns_code() throws Exception { - DeviceCodeResponse response = new DeviceCodeResponse( - "device_abc123", - "ABCD-1234", - "https://skillhub.example.com/device", - 900, - 5 - ); - - given(deviceAuthService.generateDeviceCode()).willReturn(response); - - mockMvc.perform(post("/api/v1/cli/auth/device/code") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.deviceCode").value("device_abc123")) - .andExpect(jsonPath("$.data.userCode").value("ABCD-1234")) - .andExpect(jsonPath("$.data.verificationUri").value("https://skillhub.example.com/device")) - .andExpect(jsonPath("$.data.expiresIn").value(900)) - .andExpect(jsonPath("$.data.interval").value(5)); - } - - @Test - void pollToken_returns_pending() throws Exception { - DeviceTokenResponse response = DeviceTokenResponse.pending(); - - given(deviceAuthService.pollToken("device_abc123")).willReturn(response); - - mockMvc.perform(post("/api/v1/cli/auth/device/token") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"deviceCode\": \"device_abc123\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.error").value("authorization_pending")) - .andExpect(jsonPath("$.data.accessToken").isEmpty()) - .andExpect(jsonPath("$.data.tokenType").isEmpty()); - } - - @Test - void pollToken_returns_access_token_when_authorized() throws Exception { - DeviceTokenResponse response = DeviceTokenResponse.success("sk_device_flow_token"); - - given(deviceAuthService.pollToken("device_abc123")).willReturn(response); - - mockMvc.perform(post("/api/v1/cli/auth/device/token") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"deviceCode\": \"device_abc123\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.accessToken").value("sk_device_flow_token")) - .andExpect(jsonPath("$.data.tokenType").value("Bearer")) - .andExpect(jsonPath("$.data.error").isEmpty()); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java new file mode 100644 index 00000000..8878e286 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.security.AuthFailureThrottleService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "skillhub.auth.direct.enabled=true" +}) +class DirectAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LocalAuthService localAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private AuthFailureThrottleService authFailureThrottleService; + + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + + @Test + void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_direct_1", + "direct-user", + null, + null, + "local", + Set.of("USER") + ); + given(localAuthService.login("direct-user", "Abcd123!")).willReturn(principal); + given(namespaceMemberRepository.findByUserId("usr_direct_1")).willReturn(List.of()); + given(userAccountRepository.findById("usr_direct_1")) + .willReturn(java.util.Optional.of(new UserAccount("usr_direct_1", "direct-user", null, null))); + given(userRoleBindingRepository.findByUserId("usr_direct_1")).willReturn(List.of()); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"local","username":"direct-user","password":"Abcd123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("usr_direct_1")) + .andReturn() + .getRequest() + .getSession(false); + + mockMvc.perform(get("/api/v1/auth/me").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("usr_direct_1")); + } + + @Test + void directLoginShouldRejectUnsupportedProvider() throws Exception { + mockMvc.perform(post("/api/v1/auth/direct/login") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso","username":"user","password":"pw"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java new file mode 100644 index 00000000..01baae69 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java @@ -0,0 +1,199 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceNotificationResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkbenchAppService; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class GovernanceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GovernanceWorkbenchAppService governanceWorkbenchAppService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private RbacService rbacService; + + @MockBean + private GovernanceNotificationService governanceNotificationService; + + @Test + void summary_returnsGovernanceSummary() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN"))) + .thenReturn(new GovernanceSummaryResponse(3, 2, 1)); + + mockMvc.perform(get("/api/v1/governance/summary").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pendingReviews").value(3)) + .andExpect(jsonPath("$.data.pendingPromotions").value(2)) + .andExpect(jsonPath("$.data.pendingReports").value(1)); + } + + @Test + void inbox_returnsUnifiedItems() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new GovernanceInboxItemResponse( + "REVIEW", + 1L, + "team-a/skill-a@1.0.0", + "Pending review", + "2026-03-16T02:00:00Z", + "team-a", + "skill-a" + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/governance/inbox").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].type").value("REVIEW")); + } + + @Test + void activity_returnsGovernanceActivity() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.listActivity(Set.of("SKILL_ADMIN"), 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new GovernanceActivityItemResponse( + 1L, + "REVIEW_APPROVE", + "admin", + "Admin", + "REVIEW_TASK", + "99", + "{\"comment\":\"LGTM\"}", + Instant.parse("2026-03-16T02:00:00Z").toString() + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/governance/activity").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].action").value("REVIEW_APPROVE")); + } + + @Test + void notifications_returnsCurrentUserNotifications() throws Exception { + UserNotification notification = new UserNotification( + "admin", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review approved", + "{}", + Instant.parse("2026-03-18T00:00:00Z")); + when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification)); + + mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].category").value("REVIEW")); + } + + @Test + void notifications_remainUtcAcrossJvmDefaultTimeZones() throws Exception { + UserNotification notification = new UserNotification( + "admin", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review approved", + "{}", + Instant.parse("2026-03-18T00:00:00Z")); + when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification)); + + TimeZone original = TimeZone.getDefault(); + try { + for (String zoneId : List.of("Asia/Shanghai", "America/Los_Angeles")) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].createdAt").value("2026-03-18T00:00:00Z")); + } + } finally { + TimeZone.setDefault(original); + } + } + + @Test + void markRead_returnsUpdatedNotification() throws Exception { + UserNotification notification = new UserNotification( + "admin", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review approved", + "{}", + Instant.parse("2026-03-18T00:00:00Z")); + when(governanceNotificationService.markRead(10L, "admin")).thenReturn(notification); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/governance/notifications/10/read") + .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf()) + .with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.category").value("REVIEW")); + } + + private RequestPostProcessor auth(String userId, Set roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + roles + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList() + ); + return authentication(authenticationToken); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java index 695fa221..8ddfc993 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java @@ -8,6 +8,7 @@ import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -24,9 +25,10 @@ void shouldReturnHealthStatus() throws Exception { mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.message").value("UP")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.requestId").isNotEmpty()); + .andExpect(jsonPath("$.requestId").isNotEmpty()) + .andExpect(header().string("Content-Security-Policy", org.hamcrest.Matchers.containsString("default-src 'self'"))) + .andExpect(header().string("Content-Security-Policy", org.hamcrest.Matchers.containsString("object-src 'none'"))); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java new file mode 100644 index 00000000..bb5ee3ff --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -0,0 +1,179 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.security.AuthFailureThrottleService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class LocalAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LocalAuthService localAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @MockBean + private AuthFailureThrottleService authFailureThrottleService; + + @Test + void login_returnsCurrentUserEnvelope() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "alice", + "alice@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + given(localAuthService.login("alice", "Abcd123!")).willReturn(principal); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"Abcd123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("usr_1")) + .andExpect(jsonPath("$.data.oauthProvider").value("local")); + verify(skillHubMetrics).recordLocalLogin(true); + verify(skillHubMetrics, never()).recordLocalLogin(false); + verify(authFailureThrottleService).resetIdentifier("local", "alice"); + } + + @Test + void register_returnsCreatedEnvelope() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_2", + "bob", + "bob@example.com", + "", + "local", + Set.of() + ); + given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":"bob@example.com"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.displayName").value("bob")); + verify(skillHubMetrics).incrementUserRegister(); + } + + @Test + void register_rejectsInvalidEmailFormat() throws Exception { + given(localAuthService.register("bob", "Abcd123!", "not-an-email")) + .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid")); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .header("Accept-Language", "zh-CN") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":"not-an-email"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); + } + + @Test + void login_failure_recordsFailureMetric() throws Exception { + given(localAuthService.login("alice", "wrong")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials")); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"wrong"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1"); + verify(skillHubMetrics).recordLocalLogin(false); + verify(skillHubMetrics, never()).recordLocalLogin(true); + } + + @Test + void changePassword_requiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void changePassword_withAuthentication_returnsUpdated() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java new file mode 100644 index 00000000..4ecad2e3 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.MySkillAppService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class MeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private MySkillAppService mySkillAppService; + + @Test + void listMySkills_returns_paginated_items() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + given(mySkillAppService.listMySkills("user-42", 1, 5)) + .willReturn(new PageResponse<>( + List.of(new SkillSummaryResponse( + 7L, + "copilot", + "Copilot", + "Assist with code review", + "ACTIVE", + 12L, + 3, + null, + 0, + "team-ai", + Instant.parse("2026-03-17T12:00:00Z"), + false, + new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), + null, + "PUBLISHED" + )), + 9, + 1, + 5 + )); + + mockMvc.perform(get("/api/v1/me/skills") + .with(authentication(auth)) + .param("page", "1") + .param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("copilot")) + .andExpect(jsonPath("$.data.total").value(9)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(5)); + } + + @Test + void listMyStars_returns_paginated_items() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + given(mySkillAppService.listMyStars("user-42", 0, 12)) + .willReturn(new PageResponse<>(List.of(), 0, 0, 12)); + + mockMvc.perform(get("/api/v1/me/stars").with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.total").value(0)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(12)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java new file mode 100644 index 00000000..5cc73856 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -0,0 +1,224 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class NamespacePortalControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceService namespaceService; + + @MockBean + private NamespaceGovernanceService namespaceGovernanceService; + + @MockBean + private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private NamespaceMemberCandidateService namespaceMemberCandidateService; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceRepository.findByIdIn(List.of(1L))).willReturn(List.of(namespace)); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + + mockMvc.perform(get("/api/v1/me/namespaces") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("team-a")) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")); + } + + @Test + void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow( + new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", + "team-a" + ) + ); + + mockMvc.perform(get("/api/v1/namespaces/team-a")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void archiveNamespace_returnsUpdatedNamespace() throws Exception { + Namespace archived = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceGovernanceService.archiveNamespace(eq("team-a"), eq("owner-1"), eq("cleanup"), nullable(String.class), any(), any())) + .willReturn(archived); + + mockMvc.perform(post("/api/v1/namespaces/team-a/archive") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + @Test + void listMembers_forNonMember_returns403() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + doThrow(new DomainForbiddenException("error.namespace.membership.required")) + .when(namespaceService).assertMember(1L, "guest-1"); + + mockMvc.perform(get("/api/v1/namespaces/team-a/members") + .with(auth("guest-1")) + .requestAttr("userId", "guest-1")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void searchMemberCandidates_returnsCandidates() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + given(namespaceMemberCandidateService.searchCandidates("team-a", "ali", "owner-1", 10)) + .willReturn(List.of(new NamespaceCandidateUserResponse( + "user-2", + "alice", + "alice@example.com", + "ACTIVE" + ))); + + mockMvc.perform(get("/api/v1/namespaces/team-a/member-candidates") + .param("search", "ali") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].userId").value("user-2")) + .andExpect(jsonPath("$.data[0].displayName").value("alice")); + } + + @Test + void createNamespace_requiresPlatformAdminRole() throws Exception { + mockMvc.perform(post("/api/v1/namespaces") + .with(csrf()) + .with(auth("user-1")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"slug":"team-alpha","displayName":"Team Alpha"} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void createNamespace_allowsSkillAdmin() throws Exception { + Namespace namespace = namespace(2L, "team-admin", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.createNamespace("team-admin", "Team Admin", null, "admin-1")) + .willReturn(namespace); + + mockMvc.perform(post("/api/v1/namespaces") + .with(csrf()) + .with(auth("admin-1", Set.of("SKILL_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"slug":"team-admin","displayName":"Team Admin"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-admin")); + } + + private RequestPostProcessor auth(String userId) { + return auth(userId, Set.of()); + } + + private RequestPostProcessor auth(String userId, Set platformRoles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + platformRoles + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + return authentication(authenticationToken); + } + + private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { + Namespace namespace = new Namespace(slug, "Team A", "owner-1"); + setField(namespace, "id", id); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java new file mode 100644 index 00000000..8c796f22 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -0,0 +1,204 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class NamespaceWorkflowContractTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceService namespaceService; + + @MockBean + private NamespaceGovernanceService namespaceGovernanceService; + + @MockBean + private NamespaceMemberService namespaceMemberService; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private NamespaceMemberCandidateService namespaceMemberCandidateService; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void namespaceWorkflowEndpoints_shareExpectedEnvelopeShapes() throws Exception { + Namespace namespace = namespace(7L, "team-flow", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM); + Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN); + setMemberId(adminMember, 11L); + + given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1"))) + .willReturn(namespace); + given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace); + given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any())) + .willReturn(frozen); + given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any())) + .willReturn(archived); + given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10)) + .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); + given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMember); + given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class))) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember))); + given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMember); + + mockMvc.perform(post("/api/web/namespaces") + .with(csrf()) + .with(auth("owner-1", Set.of("SKILL_ADMIN"))) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"slug\":\"team-flow\",\"displayName\":\"Team Flow\",\"description\":\"workflow\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-flow")); + + mockMvc.perform(get("/api/web/namespaces/team-flow/member-candidates") + .param("search", "admin") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].userId").value("user-admin")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/members") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-admin")); + + mockMvc.perform(get("/api/web/namespaces/team-flow/members") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")); + + mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/freeze") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("FROZEN")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/archive") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + + mockMvc.perform(delete("/api/web/namespaces/team-flow/members/user-admin") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Member removed successfully")); + } + + private RequestPostProcessor auth(String userId) { + return auth(userId, Set.of()); + } + + private RequestPostProcessor auth(String userId, Set platformRoles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + platformRoles + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + return authentication(authenticationToken); + } + + private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { + Namespace namespace = new Namespace(slug, "Team Flow", "owner-1"); + setNamespaceId(namespace, id); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private void setNamespaceId(Namespace namespace, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", id); + } + + private void setMemberId(NamespaceMember member, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(member, "id", id); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java index 2e57b0f7..b4cad51a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -84,11 +85,15 @@ class PromotionPortalControllerTest { @MockBean private ReviewPermissionChecker permissionChecker; + @MockBean + private AuditLogService auditLogService; + @Test void submitPromotion_passesNamespaceRolesToService() throws Exception { PromotionRequest request = createPromotionRequest(1L, "user-1"); stubNamespaceRoles("user-1", List.of(new NamespaceMember(5L, "user-1", NamespaceRole.ADMIN))); - given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN))) + given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); + given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN), Set.of())) .willReturn(request); stubPromotionResponse(request); @@ -121,7 +126,7 @@ void getPromotionDetail_allowsSubmitter() throws Exception { stubNamespaceRoles("user-1", List.of()); given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request)); given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); - given(permissionChecker.canReadPromotion(request, "user-1", Set.of())).willReturn(true); + given(promotionService.canViewPromotion(request, "user-1", Set.of())).willReturn(true); stubPromotionResponse(request); mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-1"))) @@ -136,7 +141,7 @@ void getPromotionDetail_forbidsUnrelatedUser() throws Exception { stubNamespaceRoles("user-9", List.of()); given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request)); given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of()); - given(permissionChecker.canReadPromotion(request, "user-9", Set.of())).willReturn(false); + given(promotionService.canViewPromotion(request, "user-9", Set.of())).willReturn(false); mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-9"))) .andExpect(status().isForbidden()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 44551a8e..77581ab4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -84,11 +85,15 @@ class ReviewPortalControllerTest { @MockBean private ReviewPermissionChecker permissionChecker; + @MockBean + private AuditLogService auditLogService; + @Test void submitReview_passesNamespaceRolesToService() throws Exception { ReviewTask task = createReviewTask(1L, 20L, "user-1"); stubNamespaceRoles("user-1", List.of(new NamespaceMember(20L, "user-1", NamespaceRole.MEMBER))); - given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER))).willReturn(task); + given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); + given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER), Set.of())).willReturn(task); stubReviewResponse(task); mockMvc.perform(post("/api/v1/reviews") @@ -130,7 +135,7 @@ void getReviewDetail_allowsSubmitter() throws Exception { given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task)); given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); - given(permissionChecker.canReadReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true); + given(reviewService.canViewReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true); stubReviewResponse(task); mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-1"))) @@ -147,7 +152,7 @@ void getReviewDetail_forbidsUnrelatedUser() throws Exception { given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task)); given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of()); - given(permissionChecker.canReadReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false); + given(reviewService.canViewReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false); mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-9"))) .andExpect(status().isForbidden()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java new file mode 100644 index 00000000..b842efbc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "skillhub.auth.session-bootstrap.enabled=true" +}) +class SessionBootstrapControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + + @Test + void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception { + given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of()); + given(userAccountRepository.findById("sso-user-1")) + .willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null))); + given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of()); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("sso-user-1")) + .andExpect(jsonPath("$.data.displayName").value("Private SSO User")) + .andReturn() + .getRequest() + .getSession(false); + + mockMvc.perform(get("/api/v1/auth/me").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("sso-user-1")) + .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")); + } + + @Test + void sessionBootstrapShouldRejectUnsupportedProvider() throws Exception { + mockMvc.perform(post("/api/v1/auth/session/bootstrap") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"unknown"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @TestConfiguration + static class SessionBootstrapTestConfig { + + @Bean + PassiveSessionAuthenticator privateSsoAuthenticator() { + return new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.of(new PlatformPrincipal( + "sso-user-1", + "Private SSO User", + null, + null, + "private-sso", + Set.of("USER") + )); + } + }; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 5022d199..6be4e061 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -2,7 +2,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import org.junit.jupiter.api.Test; @@ -13,12 +15,14 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -55,7 +59,7 @@ void getVersionDetailShouldReturnMetadataFields() throws Exception { "initial", 2, 128L, - LocalDateTime.of(2026, 3, 12, 12, 0), + Instant.parse("2026-03-12T12:00:00Z"), "{\"name\":\"demo\"}", "[{\"path\":\"SKILL.md\"}]" )); @@ -63,7 +67,6 @@ void getVersionDetailShouldReturnMetadataFields() throws Exception { mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.version").value("1.0.0")) .andExpect(jsonPath("$.data.parsedMetadataJson").value("{\"name\":\"demo\"}")) .andExpect(jsonPath("$.data.manifestJson").value("[{\"path\":\"SKILL.md\"}]")) @@ -71,6 +74,39 @@ void getVersionDetailShouldReturnMetadataFields() throws Exception { .andExpect(jsonPath("$.requestId").isNotEmpty()); } + @Test + void getVersionDetailShouldRemainUtcAcrossJvmDefaultTimeZones() throws Exception { + when(skillQueryService.getVersionDetail( + eq("team"), + eq("demo"), + eq("1.0.0"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillVersionDetailDTO( + 10L, + "1.0.0", + "PUBLISHED", + "initial", + 2, + 128L, + Instant.parse("2026-03-12T12:00:00Z"), + "{\"name\":\"demo\"}", + "[{\"path\":\"SKILL.md\"}]" + )); + + TimeZone original = TimeZone.getDefault(); + try { + for (String zoneId : List.of("Asia/Shanghai", "America/Los_Angeles")) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publishedAt").value("2026-03-12T12:00:00Z")); + } + } finally { + TimeZone.setDefault(original); + } + } + @Test void resolveVersionShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.resolveVersion( @@ -102,6 +138,63 @@ void resolveVersionShouldReturnUnifiedEnvelope() throws Exception { .andExpect(jsonPath("$.requestId").isNotEmpty()); } + @Test + void getSkillDetailShouldExposePendingPreviewFlags() throws Exception { + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillDetailDTO( + 1L, + "demo", + "Demo", + "Pending preview", + "PUBLIC", + "ACTIVE", + 10L, + 2, + null, + 0, + false, + 1L, + Instant.parse("2026-03-15T10:00:00Z"), + Instant.parse("2026-03-15T10:00:00Z"), + true, + false, + false, + false, + new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"), + null, + new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"), + "OWNER_PREVIEW" + )); + + mockMvc.perform(get("/api/web/skills/team/demo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.canSubmitPromotion").value(false)) + .andExpect(jsonPath("$.data.headlineVersion.version").value("1.1.0")) + .andExpect(jsonPath("$.data.ownerPreviewVersion.id").value(11L)) + .andExpect(jsonPath("$.data.resolutionMode").value("OWNER_PREVIEW")) + .andExpect(jsonPath("$.data.canInteract").value(false)) + .andExpect(jsonPath("$.data.canReport").value(false)); + } + + @Test + void getSkillDetailShouldReturnForbiddenForArchivedNamespace() throws Exception { + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()))) + .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); + + mockMvc.perform(get("/api/web/skills/team/demo")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( @@ -119,4 +212,21 @@ void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void listVersionsShouldExposeDownloadAvailability() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + when(skillQueryService.listVersions( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()), + any())) + .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(version))); + when(skillQueryService.isDownloadAvailable(version)).thenReturn(false); + + mockMvc.perform(get("/api/v1/skills/team/demo/versions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].downloadAvailable").value(false)); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 7043ac04..4b3b5681 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -105,12 +105,14 @@ void rate_skill_unauthenticated_returns_401() throws Exception { .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4}")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test void get_user_rating_unauthenticated_returns_401() throws Exception { mockMvc.perform(get("/api/v1/skills/10/rating")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } } 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 d0252b70..8531bf79 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 @@ -12,6 +12,7 @@ import java.util.List; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -40,11 +41,11 @@ void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception { eq("newest"), eq(0), eq(20), - eq((String) null), - eq(null))) + any(), + any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); - mockMvc.perform(get("/api/v1/skills") + mockMvc.perform(get("/api/web/skills") .param("q", "review") .param("namespace", "global")) .andExpect(status().isOk()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 2912fa7f..111d1173 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -96,7 +96,8 @@ void unstar_skill_returns_envelope() throws Exception { void star_skill_unauthenticated_returns_401() throws Exception { mockMvc.perform(put("/api/v1/skills/10/star") .with(csrf())) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -130,6 +131,31 @@ void check_starred_returns_true() throws Exception { @Test void check_starred_unauthenticated_returns_401() throws Exception { mockMvc.perform(get("/api/v1/skills/10/star")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", + "tester", + "tester@example.com", + "https://example.com/avatar.png", + "github", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(put("/api/web/skills/10/star") + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(skillStarService).star(eq(10L), eq("user-42")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java index 2daffdcd..8c664b4c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java @@ -12,8 +12,10 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Map; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -35,7 +37,7 @@ class SkillTagControllerTest { @Test void list_tags_is_public() throws Exception { - when(skillTagService.listTags(eq("team"), eq("demo"))) + when(skillTagService.listTags(eq("team"), eq("demo"), isNull(), eq(Map.of()))) .thenReturn(List.of(new SkillTag(1L, "latest", 2L, "user-1"))); mockMvc.perform(get("/api/v1/skills/team/demo/tags")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 52b98554..d97b47f3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,11 +19,20 @@ import java.util.List; import java.util.Set; +import java.util.TimeZone; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -61,4 +71,182 @@ void revoke_returns204NoContent() throws Exception { verify(apiTokenService).revokeToken(7L, "user-42"); } + + @Test + void create_rejectsNamesLongerThan64Characters() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + given(apiTokenService.rotateToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) + .willThrow(new DomainBadRequestException("validation.token.name.size")); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .header("Accept-Language", "zh-CN") + .contentType("application/json") + .content(""" + {"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void create_rejectsDuplicateActiveNames() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + given(apiTokenService.rotateToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) + .willThrow(new DomainBadRequestException("error.token.name.duplicate")); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .header("Accept-Language", "zh-CN") + .contentType("application/json") + .content(""" + {"name":"cli"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void create_passesExpirationToService() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var token = new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"); + org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-15T12:00:00Z")); + token.setExpiresAt(java.time.Instant.parse("2026-04-15T12:00:00Z")); + + given(apiTokenService.rotateToken("user-42", "cli", "[\"skill:read\",\"skill:publish\"]", "2026-04-15T12:00:00")) + .willReturn(new ApiTokenService.TokenCreateResult("sk_raw", token)); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"name":"cli","expiresAt":"2026-04-15T12:00:00"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.expiresAt").value("2026-04-15T12:00:00Z")); + } + + @Test + void list_returns_paginated_tokens() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var tokenPage = new PageImpl<>( + List.of( + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"), + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "deploy", "sk_654321", "hash-2", "[]") + ), + PageRequest.of(1, 10), + 12 + ); + var first = tokenPage.getContent().get(0); + var second = tokenPage.getContent().get(1); + org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.Instant.parse("2026-03-14T10:00:00Z")); + org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L); + org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.Instant.parse("2026-03-14T11:00:00Z")); + + given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage); + + mockMvc.perform(get("/api/v1/tokens") + .with(authentication(auth)) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].name").value("cli")) + .andExpect(jsonPath("$.data.items[1].name").value("deploy")) + .andExpect(jsonPath("$.data.total").value(12)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } + + @Test + void updateExpiration_returnsUpdatedToken() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var token = new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"); + org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-14T10:00:00Z")); + token.setExpiresAt(java.time.Instant.parse("2026-05-01T09:30:00Z")); + + given(apiTokenService.updateExpiration(7L, "user-42", "2026-05-01T09:30")) + .willReturn(token); + + mockMvc.perform(put("/api/v1/tokens/7/expiration") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"expiresAt":"2026-05-01T09:30"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(7)) + .andExpect(jsonPath("$.data.expiresAt").value("2026-05-01T09:30:00Z")); + } + + @Test + void list_returnsUtcTimestamps_evenWhenJvmDefaultTimeZoneChanges() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var tokenPage = new PageImpl<>( + List.of(new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]")), + PageRequest.of(0, 10), + 1 + ); + var token = tokenPage.getContent().getFirst(); + org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-14T10:00:00Z")); + token.setExpiresAt(java.time.Instant.parse("2026-05-01T09:30:00Z")); + + given(apiTokenService.listActiveTokens("user-42", 0, 10)).willReturn(tokenPage); + + TimeZone original = TimeZone.getDefault(); + try { + for (String zoneId : List.of("Asia/Shanghai", "America/Los_Angeles")) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + mockMvc.perform(get("/api/v1/tokens") + .with(authentication(auth)) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].createdAt").value("2026-03-14T10:00:00Z")) + .andExpect(jsonPath("$.data.items[0].expiresAt").value("2026-05-01T09:30:00Z")); + } + } finally { + TimeZone.setDefault(original); + } + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java new file mode 100644 index 00000000..2a6215b3 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminSkillControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillGovernanceService skillGovernanceService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void hideSkill_returnsUpdatedResponse() throws Exception { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) + .willReturn(skill); + + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/10/hide") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"policy\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(10)) + .andExpect(jsonPath("$.data.action").value("HIDE")); + } + + @Test + void yankVersion_returnsUpdatedResponse() throws Exception { + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.YANKED); + given(skillGovernanceService.yankVersion(org.mockito.ArgumentMatchers.eq(33L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("broken"))) + .willReturn(version); + + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/versions/33/yank") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"broken\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.versionId").value(33)) + .andExpect(jsonPath("$.data.action").value("YANK")) + .andExpect(jsonPath("$.data.status").value("YANKED")); + } + + @Test + void hideSkill_withUserAdminRole_returns403() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("USER_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/10/hide") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"policy\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void hideSkill_withSkillAdminRole_returns403() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/10/hide") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"policy\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java new file mode 100644 index 00000000..6782ae65 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -0,0 +1,160 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.report.SkillReportDisposition; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminSkillReportAppService; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class AdminSkillReportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AdminSkillReportAppService adminSkillReportAppService; + + @MockBean + private SkillReportService skillReportService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listReports_returnsPagedReports() throws Exception { + when(adminSkillReportAppService.listReports("PENDING", 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new AdminSkillReportSummaryResponse( + 99L, + 10L, + "global", + "demo-skill", + "Demo Skill", + "user-1", + "Spam", + "details", + "PENDING", + null, + null, + Instant.parse("2026-03-15T12:00:00Z"), + null + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/admin/skill-reports") + .param("status", "PENDING") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].id").value(99)) + .andExpect(jsonPath("$.data.items[0].skillSlug").value("demo-skill")) + .andExpect(jsonPath("$.data.items[0].createdAt").value("2026-03-15T12:00:00Z")); + } + + @Test + void resolveReport_returnsUpdatedEnvelope() throws Exception { + SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details"); + ReflectionTestUtils.setField(report, "id", 99L); + report.setStatus(com.iflytek.skillhub.domain.report.SkillReportStatus.RESOLVED); + when(skillReportService.resolveReport( + org.mockito.ArgumentMatchers.eq(99L), + org.mockito.ArgumentMatchers.eq("super-admin"), + org.mockito.ArgumentMatchers.eq(SkillReportDisposition.RESOLVE_AND_HIDE), + org.mockito.ArgumentMatchers.eq("handled"), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(report); + + mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve") + .with(authentication(superAdminAuth())) + .with(csrf()) + .contentType(APPLICATION_JSON) + .content("{\"comment\":\"handled\",\"disposition\":\"RESOLVE_AND_HIDE\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reportId").value(99)) + .andExpect(jsonPath("$.data.status").value("RESOLVED")); + } + + @Test + void resolveReport_withHideDispositionAndSkillAdmin_returns403() throws Exception { + mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve") + .with(authentication(adminAuth())) + .with(csrf()) + .contentType(APPLICATION_JSON) + .content("{\"comment\":\"handled\",\"disposition\":\"RESOLVE_AND_HIDE\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void listReports_withAuditorRole_returns403() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "auditor", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) + ); + + mockMvc.perform(get("/api/v1/admin/skill-reports") + .param("status", "PENDING") + .with(authentication(auth))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + private UsernamePasswordAuthenticationToken adminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + "admin", "admin", "admin@example.com", "", "github", Set.of("SKILL_ADMIN") + ); + return new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")) + ); + } + + private UsernamePasswordAuthenticationToken superAdminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + "super-admin", "super-admin", "admin@example.com", "", "github", Set.of("SUPER_ADMIN") + ); + return new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java index d255616d..9600ca7a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -49,7 +49,8 @@ class AuditLogControllerTest { @Test void listAuditLogs_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/audit-logs")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -61,7 +62,7 @@ void listAuditLogs_withAuditorRole_returns200() throws Exception { principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) ); - when(adminAuditLogAppService.listAuditLogs(0, 20, null, null)) + when(adminAuditLogAppService.listAuditLogs(0, 20, null, null, null, null, null, null, null, null)) .thenReturn(new PageResponse<>( List.of(new AuditLogItemResponse( 1L, @@ -70,6 +71,9 @@ void listAuditLogs_withAuditorRole_returns200() throws Exception { "alice", "{\"status\":\"DISABLED\"}", "127.0.0.1", + "req-1", + "USER", + "42", Instant.parse("2026-03-13T01:00:00Z"))), 1, 0, @@ -81,7 +85,10 @@ void listAuditLogs_withAuditorRole_returns200() throws Exception { .andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.total").value(1)) .andExpect(jsonPath("$.data.items[0].username").value("alice")) - .andExpect(jsonPath("$.data.items[0].details").value("{\"status\":\"DISABLED\"}")); + .andExpect(jsonPath("$.data.items[0].details").value("{\"status\":\"DISABLED\"}")) + .andExpect(jsonPath("$.data.items[0].requestId").value("req-1")) + .andExpect(jsonPath("$.data.items[0].resourceType").value("USER")) + .andExpect(jsonPath("$.data.items[0].resourceId").value("42")); } @Test @@ -93,11 +100,12 @@ void listAuditLogs_withSuperAdminRole_returns200() throws Exception { principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) ); - when(adminAuditLogAppService.listAuditLogs(0, 20, null, null)) + when(adminAuditLogAppService.listAuditLogs(0, 20, null, null, null, null, null, null, null, null)) .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -110,14 +118,45 @@ void listAuditLogs_withFilters_returns200() throws Exception { principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) ); - when(adminAuditLogAppService.listAuditLogs(0, 20, "user-1", "CREATE_SKILL")) + when(adminAuditLogAppService.listAuditLogs( + 0, + 20, + "user-1", + "CREATE_SKILL", + "req-2", + "127.0.0.1", + "SKILL", + "99", + Instant.parse("2026-03-13T00:00:00Z"), + Instant.parse("2026-03-14T00:00:00Z"))) .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); mockMvc.perform(get("/api/v1/admin/audit-logs") .param("userId", "user-1") .param("action", "CREATE_SKILL") + .param("requestId", "req-2") + .param("ipAddress", "127.0.0.1") + .param("resourceType", "SKILL") + .param("resourceId", "99") + .param("startTime", "2026-03-13T00:00:00Z") + .param("endTime", "2026-03-14T00:00:00Z") .with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } + + @Test + void listAuditLogs_withUserAdminRole_returns403() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-88", "useradmin", "useradmin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 8379a594..822a06f5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -19,9 +19,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import java.time.Instant; import java.util.List; import java.util.Set; -import java.time.LocalDateTime; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -53,7 +53,8 @@ class UserManagementControllerTest { @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -73,7 +74,7 @@ void listUsers_withUserAdminRole_returns200() throws Exception { "alice@example.com", "ACTIVE", List.of("AUDITOR"), - LocalDateTime.of(2026, 3, 13, 9, 0))), + Instant.parse("2026-03-13T09:00:00Z"))), 1, 0, 20)); @@ -85,6 +86,7 @@ void listUsers_withUserAdminRole_returns200() throws Exception { .andExpect(jsonPath("$.data.total").value(1)) .andExpect(jsonPath("$.data.items[0].id").value("user-1")) .andExpect(jsonPath("$.data.items[0].email").value("alice@example.com")) + .andExpect(jsonPath("$.data.items[0].createdAt").value("2026-03-13T09:00:00Z")) .andExpect(jsonPath("$.data.items[0].platformRoles[0]").value("AUDITOR")); } @@ -102,9 +104,24 @@ void listUsers_withSuperAdminRole_returns200() throws Exception { mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } + @Test + void listUsers_withSkillAdminRole_returns403() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-77", "skilladmin", "skilladmin@example.com", "", "github", Set.of("SKILL_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")) + ); + + mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + @Test void updateUserRole_withUserAdminRole_returns200() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java new file mode 100644 index 00000000..42f0e360 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java @@ -0,0 +1,106 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.ratelimit.RateLimiter; +import java.io.ByteArrayInputStream; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class DownloadRateLimitControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillQueryService skillQueryService; + + @MockBean + private SkillDownloadService skillDownloadService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private RateLimiter rateLimiter; + + @Test + void anonymousDownloadUsesIpAndSignedCookieBuckets() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null, + false + )); + + var result = mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .header("X-Forwarded-For", "203.0.113.10") + .with(user("anonymous-test"))) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")) + .andExpect(header().exists("Set-Cookie")) + .andReturn(); + + assertThat(result.getResponse().getHeaders("Set-Cookie")) + .anySatisfy(cookie -> { + assertThat(cookie).contains("skillhub_anon_dl="); + assertThat(cookie).contains("HttpOnly"); + assertThat(cookie).contains("SameSite=Lax"); + }); + + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + verify(rateLimiter, times(2)).tryAcquire(keyCaptor.capture(), anyInt(), anyInt()); + assertThat(keyCaptor.getAllValues()).anyMatch(key -> key.startsWith("ratelimit:download:ip:") && key.endsWith(":ns:global:slug:demo-skill:version:1.0.0")); + assertThat(keyCaptor.getAllValues()).anyMatch(key -> key.startsWith("ratelimit:download:anon:") && key.endsWith(":ns:global:slug:demo-skill:version:1.0.0")); + } + + @Test + void anonymousDownloadReturnsTooManyRequestsWhenIpBucketIsExceeded() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willAnswer(invocation -> + ((String) invocation.getArgument(0)).startsWith("ratelimit:download:ip:") ? false : true); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .header("X-Forwarded-For", "203.0.113.10") + .with(user("anonymous-test"))) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value(429)); + + verify(skillDownloadService, never()).downloadVersion(anyString(), anyString(), anyString(), anyString(), any()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java new file mode 100644 index 00000000..8945d2a9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -0,0 +1,202 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.ratelimit.RateLimiter; +import java.io.ByteArrayInputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillControllerDownloadTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillQueryService skillQueryService; + + @MockBean + private SkillDownloadService skillDownloadService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @MockBean + private RateLimiter rateLimiter; + + @Test + void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 128L, + "application/zip", + "https://download.example/presigned", + false + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://download.example/presigned")); + } + + @Test + void downloadVersion_streamsWhenPresignedUrlIsInsecureForHttpsRequest() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + "http://download.example/presigned", + false + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .header("X-Forwarded-Proto", "https") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } + + @Test + void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null, + false + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } + + @Test + void downloadVersion_allowsAnonymousForGlobalSkill() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null, + false + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .with(user("anonymous-test")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } + + @Test + void downloadVersion_forbidsAnonymousWhenServiceRejectsSkill() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("team-ai", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willThrow(new DomainForbiddenException("error.skill.access.denied", "demo-skill")); + + mockMvc.perform(get("/api/v1/skills/team-ai/demo-skill/versions/1.0.0/download") + .with(user("anonymous-test")) + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void downloadVersion_redirectDoesNotOpenContentStream() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> { + throw new AssertionError("content stream should not be opened for redirects"); + }, + "demo-skill-1.0.0.zip", + 128L, + "application/zip", + "https://download.example/presigned", + false + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://download.example/presigned")); + } + + @Test + void downloadVersion_usesPerVersionRateLimitKey() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null, + false + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isOk()); + + verify(rateLimiter).tryAcquire( + "ratelimit:download:user:test-user:ns:global:slug:demo-skill:version:1.0.0", + 120, + 60); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java new file mode 100644 index 00000000..bd7ef931 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -0,0 +1,248 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewService; +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.service.SkillSlugResolutionService; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillLifecycleControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private SkillRepository skillRepository; + + @MockBean + private SkillVersionRepository skillVersionRepository; + + @MockBean + private SkillGovernanceService skillGovernanceService; + + @MockBean + private ReviewService reviewService; + + @MockBean + private SkillPublishService skillPublishService; + + @MockBean + private SkillSlugResolutionService skillSlugResolutionService; + + @MockBean + private AuditLogService auditLogService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void archiveSkill_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/archive") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.action").value("ARCHIVE")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + @Test + void unarchiveSkill_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class))) + .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/unarchive") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.action").value("UNARCHIVE")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + @Test + void deleteVersion_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setSkillVersionId(version, 2L); + version.setStatus(SkillVersionStatus.DRAFT); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); + + mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(2)) + .andExpect(jsonPath("$.data.action").value("DELETE_VERSION")) + .andExpect(jsonPath("$.data.status").value("1.0.0")); + } + + @Test + void withdrawReview_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); + setSkillVersionId(version, 2L); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); + SkillVersion withdrawn = new SkillVersion(1L, "1.0.0", "owner"); + setSkillVersionId(withdrawn, 2L); + withdrawn.setStatus(SkillVersionStatus.DRAFT); + given(reviewService.withdrawReview(2L, "usr_1")).willReturn(withdrawn); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review") + .requestAttr("userId", "usr_1") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(2)) + .andExpect(jsonPath("$.data.action").value("WITHDRAW_REVIEW")) + .andExpect(jsonPath("$.data.status").value("DRAFT")); + } + + @Test + void rereleaseVersion_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner"); + setSkillVersionId(newVersion, 3L); + newVersion.setStatus(SkillVersionStatus.PUBLISHED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(sourceVersion, 2L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion)); + given(skillPublishService.rereleasePublishedVersion( + eq(1L), + eq("1.2.3"), + eq("1.2.4"), + eq("usr_1"), + anyMap())) + .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetVersion\":\"1.2.4\"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(3)) + .andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION")) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + } + + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { + skill.setStatus(status); + return skill; + } + + private void setNamespaceId(Namespace namespace, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", id); + } + + private void setSkillId(Skill skill, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(skill, "id", id); + } + + private void setSkillVersionId(SkillVersion version, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(version, "id", id); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java new file mode 100644 index 00000000..b7ffa2e1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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.skill.service.SkillPublishService; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillPublishControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillPublishService skillPublishService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @Test + void publish_recordsMetricsAfterSuccess() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setFileCount(1); + version.setTotalSize(128L); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries( + eq("global"), + anyList(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(12)) + .andExpect(jsonPath("$.data.slug").value("demo-skill")); + + verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); + } + + private byte[] buildZipBytes() throws Exception { + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + zip.putNextEntry(new ZipEntry("SKILL.md")); + zip.write(""" + --- + name: Demo Skill + version: 1.0.0 + --- + """.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.finish(); + return output.toByteArray(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java new file mode 100644 index 00000000..2a3dac14 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillReportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private SkillRepository skillRepository; + + @MockBean + private SkillReportService skillReportService; + + @MockBean + private SkillSlugResolutionService skillSlugResolutionService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void submitReport_returnsCreatedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + ReflectionTestUtils.setField(namespace, "id", 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", 10L); + SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details"); + ReflectionTestUtils.setField(report, "id", 99L); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "user-1", SkillSlugResolutionService.Preference.PUBLISHED)) + .willReturn(skill); + given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class))) + .willReturn(report); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/reports") + .requestAttr("userId", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"Spam\",\"details\":\"details\"}") + .with(user("user-1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.reportId").value(99)) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index d5464a60..403be311 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -1,19 +1,31 @@ package com.iflytek.skillhub.controller.support; +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockMultipartFile; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class SkillPackageArchiveExtractorTest { - private final SkillPackageArchiveExtractor extractor = new SkillPackageArchiveExtractor(); + private SkillPackageArchiveExtractor extractor; + + @BeforeEach + void setUp() { + SkillPublishProperties props = new SkillPublishProperties(); + extractor = new SkillPackageArchiveExtractor(props); + } @Test void shouldRejectPathTraversalEntry() throws Exception { @@ -31,19 +43,89 @@ void shouldRejectPathTraversalEntry() throws Exception { @Test void shouldRejectOversizedZipEntry() throws Exception { - byte[] content = new byte[1024 * 1024 + 1]; - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - createZip("large.txt", content) - ); + SkillPublishProperties props = new SkillPublishProperties(); + props.setMaxSingleFileSize(1024); // 1KB limit + SkillPackageArchiveExtractor smallExtractor = new SkillPackageArchiveExtractor(props); - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> extractor.extract(file)); + byte[] content = new byte[1025]; // >1KB + byte[] zip = createZip(Map.of("large.txt", content)); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zip); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> smallExtractor.extract(file)); assertTrue(error.getMessage().contains("File too large: large.txt")); } + @Test + void respectsConfiguredSingleFileLimit() throws Exception { + SkillPublishProperties props = new SkillPublishProperties(); + props.setMaxSingleFileSize(5 * 1024 * 1024); // 5MB + SkillPackageArchiveExtractor customExtractor = new SkillPackageArchiveExtractor(props); + + byte[] content = new byte[3 * 1024 * 1024]; // 3MB — under 5MB limit + byte[] zip = createZip(Map.of("data.md", content)); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zip); + + List entries = customExtractor.extract(file); + assertEquals(1, entries.size()); + } + + @Test + void stripsRootDirectoryWhenSingleFolder() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/SKILL.md", "---\nname: test\n---\n".getBytes(), + "my-skill/config.json", "{}".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + List entries = extractor.extract(file); + + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); + } + + @Test + void doesNotStripWhenMultipleRootEntries() throws Exception { + byte[] zipBytes = createZip(Map.of( + "SKILL.md", "---\nname: test\n---\n".getBytes(), + "config.json", "{}".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + List entries = extractor.extract(file); + + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + } + + @Test + void stripsRootDirectoryWhenZipHasExplicitDirEntry() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry("my-skill/")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("my-skill/SKILL.md")); + zos.write("---\nname: test\n---".getBytes()); + zos.closeEntry(); + } + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", baos.toByteArray()); + List entries = extractor.extract(file); + + assertEquals(1, entries.size()); + assertEquals("SKILL.md", entries.get(0).path()); + } + + @Test + void doesNotStripWhenMultipleRootDirectories() throws Exception { + byte[] zipBytes = createZip(Map.of( + "dir-a/SKILL.md", "---\nname: test\n---\n".getBytes(), + "dir-b/other.md", "# other".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + List entries = extractor.extract(file); + + assertTrue(entries.stream().anyMatch(e -> e.path().equals("dir-a/SKILL.md"))); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("dir-b/other.md"))); + } + private byte[] createZip(String entryName, String content) throws Exception { return createZip(entryName, content.getBytes(StandardCharsets.UTF_8)); } @@ -58,4 +140,17 @@ private byte[] createZip(String entryName, byte[] content) throws Exception { } return baos.toByteArray(); } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java new file mode 100644 index 00000000..80f761ea --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -0,0 +1,115 @@ +package com.iflytek.skillhub.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpSession; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.context.support.StaticMessageSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthContextFilterTest { + + private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final AuthContextFilter filter; + + AuthContextFilterTest() { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled"); + Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource, clock); + filter = new AuthContextFilter( + namespaceMemberRepository, + userAccountRepository, + apiResponseFactory, + new ObjectMapper().registerModule(new JavaTimeModule()), + true + ); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-1", "Alice", "alice@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("user-1", "Alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpSession session = (MockHttpSession) request.getSession(true); + session.setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-1")).thenReturn(java.util.Optional.of(user)); + + filter.doFilter(request, response, filterChain); + + assertEquals(401, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"code\":401")); + assertTrue(session.isInvalid()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void activeSessionUser_shouldPopulateRequestContextAndContinue() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-2", "Bob", "bob@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("user-2", "Bob", "bob@example.com", null); + user.setStatus(UserStatus.ACTIVE); + NamespaceMember member = new NamespaceMember(9L, "user-2", NamespaceRole.ADMIN); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true).setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-2")).thenReturn(java.util.Optional.of(user)); + when(namespaceMemberRepository.findByUserId("user-2")).thenReturn(List.of(member)); + + filter.doFilter(request, response, filterChain); + + assertEquals("user-2", request.getAttribute("userId")); + assertEquals(NamespaceRole.ADMIN, ((java.util.Map) request.getAttribute("userNsRoles")).get(9L)); + verify(filterChain).doFilter(request, response); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java index 7c50a88d..485534fb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java @@ -17,7 +17,9 @@ import java.io.PrintWriter; import java.io.StringWriter; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -44,12 +46,14 @@ class IdempotencyInterceptorTest { private HttpServletResponse response; private IdempotencyInterceptor interceptor; + private Clock clock; @BeforeEach void setUp() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); - interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper); + clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper, clock); } @Test @@ -115,7 +119,7 @@ void testAfterCompletionUpdatesRecord() throws Exception { IdempotencyRecord record = new IdempotencyRecord( "req-789", (String) null, (Long) null, IdempotencyStatus.PROCESSING, (Integer) null, - Instant.now(), Instant.now().plusSeconds(86400) + Instant.now(clock), Instant.now(clock).plusSeconds(86400) ); when(idempotencyRecordRepository.findByRequestId("req-789")).thenReturn(Optional.of(record)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java new file mode 100644 index 00000000..7d74d3ff --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(OutputCaptureExtension.class) +class RequestLoggingFilterTest { + + @Test + void doFilterInternal_truncatesLongRequestAndResponseBodiesInLogs(CapturedOutput output) + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + String longBody = "x".repeat(5_000); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/test"); + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setContentType("application/json"); + request.setContent(longBody.getBytes(StandardCharsets.UTF_8)); + + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + FilterChain filterChain = (req, res) -> { + req.getReader().lines().count(); + res.setContentType("application/json"); + res.getWriter().write(longBody); + }; + + filter.doFilter(request, response, filterChain); + + assertThat(output).contains("Request Body: " + "x".repeat(512) + "... [truncated, original length=5000]"); + assertThat(output).contains("Response Body: " + "x".repeat(512) + "... [truncated, original length=5000]"); + assertThat(output).doesNotContain("Request Body: " + longBody); + assertThat(output).doesNotContain("Response Body: " + longBody); + assertThat(response.getContentAsString()).isEqualTo(longBody); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java new file mode 100644 index 00000000..13c0be27 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class PrometheusEndpointTest { + + @Autowired + private SkillHubMetrics skillHubMetrics; + + @Autowired + private MeterRegistry meterRegistry; + + @Autowired + private Environment environment; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void prometheusEndpoint_exposesCustomMetrics() { + skillHubMetrics.incrementUserRegister(); + skillHubMetrics.recordLocalLogin(true); + skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW"); + + assertThat(environment.getProperty("management.endpoints.web.exposure.include")) + .contains("prometheus"); + assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.auth.login") + .tag("method", "local") + .tag("result", "success") + .counter() + .count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.skill.publish") + .tag("namespace", "global") + .tag("status", "PENDING_REVIEW") + .counter() + .count()).isEqualTo(1.0d); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java new file mode 100644 index 00000000..74ed9560 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.metrics; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class PrometheusSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void prometheusEndpointShouldNotBeAnonymous() throws Exception { + mockMvc.perform(get("/actuator/prometheus")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java new file mode 100644 index 00000000..f68e819e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SensitiveLogSanitizerTest { + + private final SensitiveLogSanitizer sanitizer = new SensitiveLogSanitizer(); + + @Test + void shouldRedactSensitiveQueryParameters() { + String sanitized = sanitizer.sanitizeQuery("returnTo=%2Fdashboard&token=abc123&password=secret&code=xyz"); + + assertThat(sanitized).contains("returnTo=%2Fdashboard"); + assertThat(sanitized).contains("token=[REDACTED]"); + assertThat(sanitized).contains("password=[REDACTED]"); + assertThat(sanitized).contains("code=[REDACTED]"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java index 5f2d64cc..2b2b255e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java @@ -31,14 +31,35 @@ void listAuditLogs_returnsJdbcBackedPage() { "alice", "{\"status\":\"DISABLED\"}", "127.0.0.1", + "req-1", + "USER", + "42", Instant.parse("2026-03-13T01:00:00Z") ))); - PageResponse response = service.listAuditLogs(0, 20, "user-1", "USER_STATUS_CHANGE"); + PageResponse response = service.listAuditLogs( + 0, + 20, + "user-1", + "USER_STATUS_CHANGE", + "req-1", + "127.0.0.1", + "USER", + "42", + Instant.parse("2026-03-13T00:00:00Z"), + Instant.parse("2026-03-14T00:00:00Z")); assertThat(response.total()).isEqualTo(1); assertThat(response.items()).hasSize(1); verify(jdbcTemplate).queryForObject(contains("al.actor_user_id = :userId"), any(MapSqlParameterSource.class), eq(Long.class)); - verify(jdbcTemplate).query(contains("al.action = :action"), any(MapSqlParameterSource.class), any(RowMapper.class)); + verify(jdbcTemplate).query(contains("al.action IN (:actions)"), any(MapSqlParameterSource.class), any(RowMapper.class)); + verify(jdbcTemplate).query( + contains("al.request_id = :requestId"), + any(MapSqlParameterSource.class), + any(RowMapper.class)); + verify(jdbcTemplate).query( + contains("CAST(al.target_id AS TEXT) = :resourceId"), + any(MapSqlParameterSource.class), + any(RowMapper.class)); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 03b7b853..4dd22e9c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -18,7 +18,7 @@ import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @@ -61,6 +61,20 @@ void listUsers_returnsPagedUsersFromRepository() { .isEqualTo(List.of("AUDITOR")); } + @Test + void listUsers_defaultsToUserRoleWhenNoExplicitBindingExists() { + UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE); + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + when(adminUserSearchRepository.search(null, null, pageable)) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userRoleBindingRepository.findByUserIdIn(List.of("user-1"))).thenReturn(List.of()); + + PageResponse response = service.listUsers(null, null, 0, 20); + + assertThat(response.items().get(0)).extracting("platformRoles") + .isEqualTo(List.of("USER")); + } + @Test void listUsers_withInvalidStatus_throwsBadRequest() { assertThrows(DomainBadRequestException.class, () -> service.listUsers(null, "BANNED", 0, 20)); @@ -133,8 +147,8 @@ void updateUserStatus_withUnknownUser_throwsNotFound() { private UserAccount user(String id, String displayName, String email, UserStatus status) { UserAccount user = new UserAccount(id, displayName, email, null); user.setStatus(status); - ReflectionTestUtils.setField(user, "createdAt", LocalDateTime.of(2026, 3, 13, 9, 0)); - ReflectionTestUtils.setField(user, "updatedAt", LocalDateTime.of(2026, 3, 13, 9, 0)); + ReflectionTestUtils.setField(user, "createdAt", Instant.parse("2026-03-13T09:00:00Z")); + ReflectionTestUtils.setField(user, "updatedAt", Instant.parse("2026-03-13T09:00:00Z")); return user; } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java new file mode 100644 index 00000000..35ca8d75 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.config.DirectAuthProperties; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; + +class AuthMethodCatalogTest { + + @Test + void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + DirectAuthProperties directAuthProperties = new DirectAuthProperties(); + directAuthProperties.setEnabled(true); + AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); + bootstrapProperties.setEnabled(true); + + DirectAuthProvider directProvider = new DirectAuthProvider() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public String displayName() { + return "Enterprise Password"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + throw new UnsupportedOperationException("not used in catalog test"); + } + }; + + PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public String displayName() { + return "Enterprise SSO"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.empty(); + } + }; + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + directAuthProperties, + bootstrapProperties, + List.of(directProvider), + List.of(bootstrapProvider) + ); + + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id() + ":" + method.displayName()) + .contains( + "local-password:Local Account", + "direct-private-sso:Enterprise Password", + "bootstrap-private-sso:Enterprise SSO" + ); + } + + @Test + void listMethodsShouldFallBackToProviderCodeWhenDisplayNameIsNotOverridden() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + DirectAuthProperties directAuthProperties = new DirectAuthProperties(); + directAuthProperties.setEnabled(true); + AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); + bootstrapProperties.setEnabled(true); + + DirectAuthProvider directProvider = new DirectAuthProvider() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + return mock(PlatformPrincipal.class); + } + }; + + PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.empty(); + } + }; + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + directAuthProperties, + bootstrapProperties, + List.of(directProvider), + List.of(bootstrapProvider) + ); + + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id() + ":" + method.displayName()) + .contains( + "direct-private-sso:private-sso", + "bootstrap-private-sso:private-sso" + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java new file mode 100644 index 00000000..6e20f7e4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java @@ -0,0 +1,253 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +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.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.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.time.Instant; +import java.util.List; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class GovernanceWorkbenchAppServiceTest { + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private PromotionRequestRepository promotionRequestRepository; + + @Mock + private SkillReportRepository skillReportRepository; + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private AdminAuditLogAppService adminAuditLogAppService; + + private GovernanceWorkbenchAppService service; + + @BeforeEach + void setUp() { + service = new GovernanceWorkbenchAppService( + reviewTaskRepository, + promotionRequestRepository, + skillReportRepository, + skillRepository, + skillVersionRepository, + namespaceRepository, + adminAuditLogAppService + ); + } + + @Test + void summary_returnsAllPendingCountsForPlatformGovernor() { + when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner")))); + when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createPromotionRequest(2L, 101L, 12L, "owner")))); + when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReport(3L, 101L, 11L, "reporter")))); + + GovernanceSummaryResponse response = service.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN")); + + assertThat(response.pendingReviews()).isEqualTo(1); + assertThat(response.pendingPromotions()).isEqualTo(1); + assertThat(response.pendingReports()).isEqualTo(1); + } + + @Test + void summary_limitsReviewsToManagedNamespacesForNamespaceAdmin() { + when(reviewTaskRepository.findByNamespaceIdAndStatus(11L, ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner")))); + + GovernanceSummaryResponse response = service.getSummary( + "ns-admin", + Map.of(11L, NamespaceRole.ADMIN, 12L, NamespaceRole.MEMBER), + Set.of() + ); + + assertThat(response.pendingReviews()).isEqualTo(1); + assertThat(response.pendingPromotions()).isZero(); + assertThat(response.pendingReports()).isZero(); + } + + @Test + void listInbox_combinesReviewPromotionAndReportItems() { + ReviewTask reviewTask = createReviewTask(1L, 11L, 101L, "owner"); + PromotionRequest promotionRequest = createPromotionRequest(2L, 101L, 12L, "owner"); + SkillReport report = createReport(3L, 101L, 11L, "reporter"); + stubReviewContext(reviewTask, "team-a", "skill-a"); + stubPromotionContext(promotionRequest, "team-a", "skill-a", "global"); + stubReportContext(report, "team-a", "skill-a"); + + when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(reviewTask))); + when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(promotionRequest))); + when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(report))); + + PageResponse response = service.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20); + + assertThat(response.total()).isEqualTo(3); + assertThat(response.items()).hasSize(3); + } + + @Test + void listActivity_projectsGovernanceAuditEntries() { + when(adminAuditLogAppService.listAuditLogsByActions( + eq(0), + eq(20), + isNull(), + eq(Set.of( + "REVIEW_SUBMIT", + "REVIEW_APPROVE", + "REVIEW_REJECT", + "REVIEW_WITHDRAW", + "PROMOTION_SUBMIT", + "PROMOTION_APPROVE", + "PROMOTION_REJECT", + "REPORT_SKILL", + "RESOLVE_SKILL_REPORT", + "DISMISS_SKILL_REPORT", + "HIDE_SKILL", + "ARCHIVE_SKILL", + "UNHIDE_SKILL", + "UNARCHIVE_SKILL" + )), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull())) + .thenReturn(new PageResponse<>( + List.of( + new AuditLogItemResponse( + 1L, + "REVIEW_APPROVE", + "admin", + "Admin", + "{\"comment\":\"LGTM\"}", + "127.0.0.1", + "req-1", + "REVIEW_TASK", + "99", + Instant.parse("2026-03-16T02:00:00Z") + ) + ), + 1, + 0, + 20 + )); + + PageResponse response = service.listActivity(Set.of("SKILL_ADMIN"), 0, 20); + + assertThat(response.total()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + } + + private void stubReviewContext(ReviewTask task, String namespaceSlug, String skillSlug) { + SkillVersion version = new SkillVersion(task.getSkillVersionId(), "1.0.0", task.getSubmittedBy()); + setField(version, "id", task.getSkillVersionId()); + setField(version, "skillId", task.getSkillVersionId()); + Skill skill = new Skill(task.getNamespaceId(), skillSlug, task.getSubmittedBy(), SkillVisibility.PUBLIC); + setField(skill, "id", task.getSkillVersionId()); + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, task.getSubmittedBy()); + setField(namespace, "id", task.getNamespaceId()); + when(skillVersionRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(version)); + when(skillRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(task.getNamespaceId())).thenReturn(Optional.of(namespace)); + } + + private void stubPromotionContext(PromotionRequest request, String sourceNamespaceSlug, String skillSlug, String targetNamespaceSlug) { + Skill skill = new Skill(11L, skillSlug, request.getSubmittedBy(), SkillVisibility.PUBLIC); + setField(skill, "id", request.getSourceSkillId()); + SkillVersion version = new SkillVersion(request.getSourceSkillId(), "1.0.0", request.getSubmittedBy()); + setField(version, "id", request.getSourceVersionId()); + Namespace sourceNamespace = new Namespace(sourceNamespaceSlug, sourceNamespaceSlug, request.getSubmittedBy()); + setField(sourceNamespace, "id", 11L); + Namespace targetNamespace = new Namespace(targetNamespaceSlug, targetNamespaceSlug, request.getSubmittedBy()); + setField(targetNamespace, "id", request.getTargetNamespaceId()); + when(skillRepository.findById(request.getSourceSkillId())).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(request.getSourceVersionId())).thenReturn(Optional.of(version)); + when(namespaceRepository.findById(11L)).thenReturn(Optional.of(sourceNamespace)); + when(namespaceRepository.findById(request.getTargetNamespaceId())).thenReturn(Optional.of(targetNamespace)); + } + + private void stubReportContext(SkillReport report, String namespaceSlug, String skillSlug) { + Skill skill = new Skill(report.getNamespaceId(), skillSlug, report.getReporterId(), SkillVisibility.PUBLIC); + setField(skill, "id", report.getSkillId()); + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, report.getReporterId()); + setField(namespace, "id", report.getNamespaceId()); + when(skillRepository.findById(report.getSkillId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(report.getNamespaceId())).thenReturn(Optional.of(namespace)); + } + + private ReviewTask createReviewTask(Long id, Long namespaceId, Long skillVersionId, String submittedBy) { + ReviewTask task = new ReviewTask(skillVersionId, namespaceId, submittedBy); + setField(task, "id", id); + return task; + } + + private PromotionRequest createPromotionRequest(Long id, Long sourceVersionId, Long targetNamespaceId, String submittedBy) { + PromotionRequest request = new PromotionRequest(sourceVersionId, sourceVersionId, targetNamespaceId, submittedBy); + setField(request, "id", id); + setField(request, "sourceSkillId", sourceVersionId); + return request; + } + + private SkillReport createReport(Long id, Long skillId, Long namespaceId, String reporterId) { + SkillReport report = new SkillReport(skillId, namespaceId, reporterId, "Spam", "details"); + setField(report, "id", id); + return report; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java new file mode 100644 index 00000000..af381814 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -0,0 +1,196 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +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.service.SkillLifecycleProjectionService; +import com.iflytek.skillhub.domain.social.SkillStar; +import com.iflytek.skillhub.domain.social.SkillStarRepository; +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 org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class MySkillAppServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private SkillStarRepository skillStarRepository; + + @Mock + private PromotionRequestRepository promotionRequestRepository; + + private MySkillAppService service; + private SkillLifecycleProjectionService skillLifecycleProjectionService; + + @BeforeEach + void setUp() { + skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository); + service = new MySkillAppService( + skillRepository, + namespaceRepository, + skillVersionRepository, + skillStarRepository, + promotionRequestRepository, + skillLifecycleProjectionService + ); + } + + @Test + void listMyStars_loadsAllPagesOfStars() { + SkillStar firstStar = new SkillStar(1L, "user-1"); + ReflectionTestUtils.setField(firstStar, "createdAt", Instant.parse("2026-03-14T10:00:00Z")); + SkillStar secondStar = new SkillStar(2L, "user-1"); + ReflectionTestUtils.setField(secondStar, "createdAt", Instant.parse("2026-03-14T11:00:00Z")); + + given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 1))) + .willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 1), 2)); + + Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC); + firstSkill.setDisplayName("First Skill"); + firstSkill.setSummary("first summary"); + ReflectionTestUtils.setField(firstSkill, "id", 1L); + ReflectionTestUtils.setField(firstSkill, "starCount", 1); + ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L); + ReflectionTestUtils.setField(firstSkill, "updatedAt", Instant.parse("2026-03-14T10:00:00Z")); + + Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC); + secondSkill.setDisplayName("Second Skill"); + secondSkill.setSummary("second summary"); + ReflectionTestUtils.setField(secondSkill, "id", 2L); + ReflectionTestUtils.setField(secondSkill, "starCount", 2); + ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L); + ReflectionTestUtils.setField(secondSkill, "updatedAt", Instant.parse("2026-03-14T11:00:00Z")); + + given(skillRepository.findByIdIn(List.of(2L))).willReturn(List.of(secondSkill)); + given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of()); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); + + var stars = service.listMyStars("user-1", 1, 1); + + assertThat(stars.total()).isEqualTo(2); + assertThat(stars.page()).isEqualTo(1); + assertThat(stars.size()).isEqualTo(1); + assertThat(stars.items()).extracting("slug").containsExactly("second-skill"); + } + + @Test + void listMySkills_includes_pendingReviewVersionWhenNoPublishedPointerExists() { + Skill skill = new Skill(101L, "draft-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Draft Skill"); + skill.setSummary("pending review"); + ReflectionTestUtils.setField(skill, "id", 1L); + ReflectionTestUtils.setField(skill, "updatedAt", Instant.parse("2026-03-15T10:00:00Z")); + + SkillVersion pendingVersion = new SkillVersion(1L, "1.0.0", "user-1"); + pendingVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); + ReflectionTestUtils.setField(pendingVersion, "id", 11L); + ReflectionTestUtils.setField(pendingVersion, "createdAt", Instant.parse("2026-03-15T09:30:00Z")); + + given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); + given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of()); + given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).willReturn(List.of(pendingVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); + + var skills = service.listMySkills("user-1", 0, 10); + + assertThat(skills.items()).hasSize(1); + assertThat(skills.items().get(0).headlineVersion()).isNotNull(); + assertThat(skills.items().get(0).headlineVersion().version()).isEqualTo("1.0.0"); + assertThat(skills.items().get(0).headlineVersion().status()).isEqualTo("PENDING_REVIEW"); + assertThat(skills.items().get(0).ownerPreviewVersion()).isNotNull(); + assertThat(skills.items().get(0).ownerPreviewVersion().id()).isEqualTo(11L); + assertThat(skills.items().get(0).canSubmitPromotion()).isFalse(); + } + + @Test + void listMySkills_marksTeamPublishedSkillAsPromotable() { + Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Team Skill"); + skill.setSummary("published"); + ReflectionTestUtils.setField(skill, "id", 2L); + ReflectionTestUtils.setField(skill, "updatedAt", Instant.parse("2026-03-15T11:00:00Z")); + + SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(publishedVersion, "id", 22L); + ReflectionTestUtils.setField(publishedVersion, "createdAt", Instant.parse("2026-03-15T10:30:00Z")); + + Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); + ReflectionTestUtils.setField(namespace, "id", 101L); + + given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); + given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty()); + given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty()); + + var skills = service.listMySkills("user-1", 0, 10); + + assertThat(skills.items()).hasSize(1); + assertThat(skills.items().get(0).publishedVersion()).isNotNull(); + assertThat(skills.items().get(0).publishedVersion().id()).isEqualTo(22L); + assertThat(skills.items().get(0).headlineVersion()).isNotNull(); + assertThat(skills.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED"); + assertThat(skills.items().get(0).canSubmitPromotion()).isTrue(); + } + + @Test + void listMySkills_hidesPromotionWhenPendingRequestExists() { + Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Team Skill"); + ReflectionTestUtils.setField(skill, "id", 2L); + + SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(publishedVersion, "id", 22L); + ReflectionTestUtils.setField(publishedVersion, "createdAt", Instant.parse("2026-03-15T10:30:00Z")); + + Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); + ReflectionTestUtils.setField(namespace, "id", 101L); + + given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); + given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)) + .willReturn(Optional.of(new PromotionRequest(2L, 22L, 999L, "user-1"))); + + var skills = service.listMySkills("user-1", 0, 10); + + assertThat(skills.items()).hasSize(1); + assertThat(skills.items().get(0).canSubmitPromotion()).isFalse(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java new file mode 100644 index 00000000..46e55f9f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceMemberCandidateServiceTest { + + @Mock + private NamespaceService namespaceService; + + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + @Test + void searchCandidates_shouldFilterExistingMembers() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + + NamespaceMemberCandidateService service = new NamespaceMemberCandidateService( + namespaceService, + namespaceAccessPolicy, + namespaceMemberRepository, + userAccountRepository + ); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + doNothing().when(namespaceService).assertAdminOrOwner(1L, "owner-1"); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + when(namespaceMemberRepository.findByNamespaceId(1L, PageRequest.of(0, 500))) + .thenReturn(new PageImpl<>(List.of(new NamespaceMember(1L, "user-1", NamespaceRole.MEMBER)))); + when(userAccountRepository.search("ali", UserStatus.ACTIVE, PageRequest.of(0, 10))) + .thenReturn(new PageImpl<>(List.of( + new UserAccount("user-1", "alice", "alice@example.com", null), + new UserAccount("user-2", "alina", "alina@example.com", null) + ))); + + List result = service.searchCandidates("team-a", "ali", "owner-1", 10); + + assertEquals(1, result.size()); + assertEquals("user-2", result.getFirst().userId()); + verify(namespaceService).assertAdminOrOwner(1L, "owner-1"); + } + + @Test + void searchCandidates_shouldRejectReadonlyNamespace() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + + NamespaceMemberCandidateService service = new NamespaceMemberCandidateService( + namespaceService, + namespaceAccessPolicy, + namespaceMemberRepository, + userAccountRepository + ); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + doNothing().when(namespaceService).assertAdminOrOwner(1L, "owner-1"); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + service.searchCandidates("team-a", "ali", "owner-1", 10)); + } + + @Test + void searchCandidates_shouldRejectGlobalNamespaceBeforeMembershipChecks() { + Namespace namespace = new Namespace("global", "Global", "system"); + setField(namespace, "id", 1L); + namespace.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL); + + NamespaceMemberCandidateService service = new NamespaceMemberCandidateService( + namespaceService, + namespaceAccessPolicy, + namespaceMemberRepository, + userAccountRepository + ); + + when(namespaceService.getNamespaceBySlug("global")).thenReturn(namespace); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + service.searchCandidates("global", "ali", "guest-1", 10)); + + assertEquals("error.namespace.system.immutable", exception.messageCode()); + verify(namespaceService, org.mockito.Mockito.never()).assertAdminOrOwner(1L, "guest-1"); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java new file mode 100644 index 00000000..dc176c8b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -0,0 +1,164 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; +import com.iflytek.skillhub.search.SearchQueryService; +import com.iflytek.skillhub.search.SearchResult; +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSearchAppServiceTest { + + @Mock + private SearchQueryService searchQueryService; + + @Mock + private SkillRepository skillRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private NamespaceService namespaceService; + + private SkillSearchAppService service; + + @BeforeEach + void setUp() { + service = new SkillSearchAppService( + searchQueryService, + skillRepository, + namespaceRepository, + namespaceService, + new VisibilityChecker(), + new SkillLifecycleProjectionService(skillVersionRepository) + ); + } + + @Test + void search_shouldExcludeArchivedNamespaceSkillsForAnonymousUsers() { + Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC); + setField(archivedSkill, "id", 10L); + + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(archivedSkill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(archivedNamespace)); + + SkillSearchAppService.SearchResponse response = service.search("archive", null, "newest", 0, 20, null, null); + + assertEquals(0, response.items().size()); + assertEquals(0, response.total()); + } + + @Test + void search_shouldFillVisiblePageAcrossArchivedNamespaceResults() { + Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC); + setField(archivedSkill, "id", 10L); + archivedSkill.setLatestVersionId(110L); + Skill visibleSkill = new Skill(2L, "visible-skill", "owner-1", SkillVisibility.PUBLIC); + setField(visibleSkill, "id", 11L); + visibleSkill.setLatestVersionId(111L); + + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + Namespace activeNamespace = new Namespace("team-a", "Team A", "owner-1"); + setField(activeNamespace, "id", 2L); + activeNamespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(archivedSkill, visibleSkill)); + when(namespaceRepository.findByIdIn(List.of(1L, 2L))).thenReturn(List.of(archivedNamespace, activeNamespace)); + + SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); + + assertEquals(1, response.items().size()); + assertEquals("visible-skill", response.items().getFirst().slug()); + assertEquals(1, response.total()); + } + + @Test + void search_shouldHideArchivedNamespaceFilterForAnonymousUsers() { + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespaceBySlugForRead("archived-team", null, Map.of())).thenThrow( + new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", + "archived-team" + ) + ); + + assertThrows( + com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class, + () -> service.search("skill", "archived-team", "newest", 0, 20, null, Map.of()) + ); + } + + @Test + void search_shouldExcludeHiddenSkillsForRegularUsers() { + Skill visibleSkill = new Skill(1L, "visible-skill", "owner-1", SkillVisibility.PUBLIC); + setField(visibleSkill, "id", 10L); + visibleSkill.setLatestVersionId(101L); + + Skill hiddenSkill = new Skill(1L, "hidden-skill", "owner-2", SkillVisibility.PUBLIC); + setField(hiddenSkill, "id", 11L); + hiddenSkill.setLatestVersionId(102L); + hiddenSkill.setHidden(true); + + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(visibleSkill, hiddenSkill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + + SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of()); + + assertEquals(1, response.items().size()); + assertEquals("visible-skill", response.items().getFirst().slug()); + assertEquals(1, response.total()); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} 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 67294b40..95f0f0ee 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 @@ -7,7 +7,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -22,7 +24,8 @@ class IdempotencyCleanupTaskTest { @BeforeEach void setUp() { - cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository); + Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository, clock); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/time/BackendTimeGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/time/BackendTimeGuardrailTest.java new file mode 100644 index 00000000..c1ec8c21 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/time/BackendTimeGuardrailTest.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.time; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class BackendTimeGuardrailTest { + + private static final Pattern LOCAL_DATE_TIME_NOW_PATTERN = + Pattern.compile("\\bLocalDateTime\\s*\\.\\s*now\\s*\\("); + + private static final Pattern ENTITY_ANNOTATION_PATTERN = + Pattern.compile("^\\s*@Entity\\b", Pattern.MULTILINE); + + private static final Pattern LOCAL_DATE_TIME_FIELD_PATTERN = + Pattern.compile("^\\s*private\\s+LocalDateTime\\s+\\w+\\s*;", Pattern.MULTILINE); + + @Test + void productionCode_mustNotIntroduceLocalDateTimeNow_calls() throws IOException { + List violations = new ArrayList<>(); + + for (Path file : productionJavaFiles()) { + String content = Files.readString(file); + if (LOCAL_DATE_TIME_NOW_PATTERN.matcher(content).find()) { + violations.add(relativeToRepo(file) + " uses LocalDateTime.now()"); + } + } + + assertThat(violations).isEmpty(); + } + + @Test + void entities_mustNotUseLocalDateTime_fields() throws IOException { + List violations = new ArrayList<>(); + + for (Path file : productionJavaFiles()) { + String content = Files.readString(file); + if (!ENTITY_ANNOTATION_PATTERN.matcher(content).find()) { + continue; + } + if (LOCAL_DATE_TIME_FIELD_PATTERN.matcher(content).find()) { + violations.add(relativeToRepo(file) + " declares LocalDateTime field(s)"); + } + } + + assertThat(violations).isEmpty(); + } + + private List productionJavaFiles() throws IOException { + List files = new ArrayList<>(); + for (String module : List.of("skillhub-app", "skillhub-auth", "skillhub-domain")) { + Path root = repoRoot().resolve("server").resolve(module).resolve("src/main/java"); + try (var stream = Files.walk(root)) { + stream.filter(path -> path.toString().endsWith(".java")).forEach(files::add); + } + } + return files; + } + + private Path repoRoot() { + return Path.of("").toAbsolutePath().getParent().getParent(); + } + + private String relativeToRepo(Path file) { + return repoRoot().relativize(file).toString(); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 599a5049..3fa53444 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -2,13 +2,13 @@ spring: main: allow-bean-definition-overriding: true datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: - ddl-auto: none + ddl-auto: create-drop database-platform: org.hibernate.dialect.H2Dialect flyway: enabled: false @@ -34,5 +34,7 @@ spring: user-info-uri: https://api.github.com/user skillhub: + auth: + enforce-active-user-check: false access-policy: mode: OPEN diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index e0b7f665..73408ce9 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 skillhub-auth diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java new file mode 100644 index 00000000..ef00284b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.auth.bootstrap; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * Extension point for establishing a SkillHub session from an external passive session, + * such as an SSO cookie already present on the request. + */ +public interface PassiveSessionAuthenticator { + + String providerCode(); + + default String displayName() { + return providerCode(); + } + + Optional authenticate(HttpServletRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 828a6755..664e9ba4 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; +import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; @@ -11,8 +12,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.http.HttpMethod; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @@ -21,13 +25,28 @@ import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { + private static final String CONTENT_SECURITY_POLICY = String.join("; ", + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https://fonts.gstatic.com", + "connect-src 'self' ws: wss: http://localhost:* https://localhost:*", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + "form-action 'self'"); private final CustomOAuth2UserService customOAuth2UserService; + private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter; @@ -37,6 +56,7 @@ public class SecurityConfig { private final ObjectProvider mockAuthFilterProvider; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, ApiTokenAuthenticationFilter apiTokenAuthenticationFilter, @@ -45,6 +65,7 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, AccessDeniedHandler apiAccessDeniedHandler, ObjectProvider mockAuthFilterProvider) { this.customOAuth2UserService = customOAuth2UserService; + this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter; @@ -58,53 +79,110 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { var csrfHandler = new CsrfTokenRequestAttributeHandler(); csrfHandler.setCsrfRequestAttributeName(null); + RequestMatcher csrfIgnoreMatcher = request -> { + String path = request.getRequestURI(); + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + return true; + } + if (path == null) { + return false; + } + return path.startsWith("/api/") + || path.equals("/api/v1/publish") + || path.startsWith("/api/v1/auth/device/"); + }; http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(csrfHandler) - .ignoringRequestMatchers("/api/v1/cli/**", "/api/compat/**") + .ignoringRequestMatchers(csrfIgnoreMatcher) ) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/v1/health", + "/api/v1/search", + "/api/v1/resolve/**", + "/api/v1/download/**", "/api/v1/auth/providers", + "/api/v1/auth/methods", "/api/v1/auth/me", - "/api/v1/cli/auth/device/**", - "/api/v1/cli/check", + "/api/v1/auth/session/bootstrap", + "/api/v1/auth/direct/login", + "/api/v1/auth/local/**", + "/api/v1/auth/device/**", + "/api/v1/check", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/.well-known/**", - "/api/compat/v1/search", - "/api/compat/v1/resolve/**" + "/api/v1/search", + "/api/v1/resolve/**", + "/api/v1/download/**" ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/skills/*/star", "/api/v1/skills/*/rating").authenticated() + .requestMatchers("/actuator/prometheus").hasAnyRole("SUPER_ADMIN", "AUDITOR") + .requestMatchers( + HttpMethod.GET, + "/api/v1/skills/*/star", + "/api/v1/skills/*/rating", + "/api/web/skills/*/star", + "/api/web/skills/*/rating" + ).authenticated() .requestMatchers( HttpMethod.GET, "/api/v1/skills", "/api/v1/skills/*/*", "/api/v1/skills/*/*/versions", "/api/v1/skills/*/*/versions/*", + "/api/v1/skills/*/*/download", + "/api/v1/skills/*/*/versions/*/download", "/api/v1/skills/*/*/versions/*/files", "/api/v1/skills/*/*/versions/*/file", "/api/v1/skills/*/*/resolve", - "/api/v1/skills/*/*/download", - "/api/v1/skills/*/*/versions/*/download", "/api/v1/skills/*/*/tags", + "/api/v1/skills/*/*/tags/*/download", "/api/v1/skills/*/*/tags/*/files", "/api/v1/skills/*/*/tags/*/file", - "/api/v1/skills/*/*/tags/*/download" + "/api/web/skills", + "/api/web/skills/*/*", + "/api/web/skills/*/*/versions", + "/api/web/skills/*/*/versions/*", + "/api/web/skills/*/*/download", + "/api/web/skills/*/*/versions/*/download", + "/api/web/skills/*/*/versions/*/files", + "/api/web/skills/*/*/versions/*/file", + "/api/web/skills/*/*/resolve", + "/api/web/skills/*/*/tags", + "/api/web/skills/*/*/tags/*/download", + "/api/web/skills/*/*/tags/*/files", + "/api/web/skills/*/*/tags/*/file" ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll() - .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR") + .requestMatchers( + HttpMethod.GET, + "/api/v1/namespaces", + "/api/v1/namespaces/*", + "/api/web/namespaces", + "/api/web/namespaces/*" + ).permitAll() + .requestMatchers("/api/v1/admin/**").authenticated() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .successHandler(successHandler) .failureHandler(failureHandler) ) + .headers(headers -> headers + .contentTypeOptions(contentTypeOptions -> {}) + .contentSecurityPolicy(csp -> csp.policyDirectives(CONTENT_SECURITY_POLICY)) + .frameOptions(frameOptions -> frameOptions.deny()) + .httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31536000)) + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) @@ -131,4 +209,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index 7a60e0df..e9ac19a5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -32,7 +32,7 @@ public class DeviceAuthService { public DeviceAuthService(RedisTemplate redisTemplate, ApiTokenService apiTokenService, - @Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) { + @Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) { this.redisTemplate = redisTemplate; this.apiTokenService = apiTokenService; this.verificationUri = verificationUri; @@ -109,7 +109,7 @@ private DeviceTokenResponse redeemAuthorizedDeviceCode(String deviceCode, Device throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); } - String token = apiTokenService.createToken( + String token = apiTokenService.rotateToken( data.getUserId(), CLI_DEVICE_TOKEN_NAME, CLI_DEVICE_SCOPE_JSON diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java new file mode 100644 index 00000000..eb9b4331 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.auth.direct; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +/** + * Extension point for username/password style direct authentication sources. + */ +public interface DirectAuthProvider { + + String providerCode(); + + default String displayName() { + return providerCode(); + } + + PlatformPrincipal authenticate(DirectAuthRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java new file mode 100644 index 00000000..83d3c65e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.direct; + +public record DirectAuthRequest( + String username, + String password +) {} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java new file mode 100644 index 00000000..181b85a3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.auth.direct; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import org.springframework.stereotype.Component; + +@Component +public class LocalDirectAuthProvider implements DirectAuthProvider { + + private final LocalAuthService localAuthService; + + public LocalDirectAuthProvider(LocalAuthService localAuthService) { + this.localAuthService = localAuthService; + } + + @Override + public String providerCode() { + return "local"; + } + + @Override + public String displayName() { + return "Local Account"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + return localAuthService.login(request.username(), request.password()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index 9b5629dd..d1acce99 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -21,7 +22,7 @@ public class ApiToken { @Column(name = "user_id", nullable = false) private String userId; - @Column(nullable = false, length = 128) + @Column(nullable = false, length = 64) private String name; @Column(name = "token_prefix", nullable = false, length = 16) @@ -35,16 +36,16 @@ public class ApiToken { private String scopeJson; @Column(name = "expires_at") - private LocalDateTime expiresAt; + private Instant expiresAt; @Column(name = "last_used_at") - private LocalDateTime lastUsedAt; + private Instant lastUsedAt; @Column(name = "revoked_at") - private LocalDateTime revokedAt; + private Instant revokedAt; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected ApiToken() {} @@ -59,22 +60,28 @@ public ApiToken(String userId, String name, String tokenPrefix, String tokenHash } @PrePersist - void prePersist() { this.createdAt = LocalDateTime.now(); } + void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } + public String getSubjectType() { return subjectType; } + public String getSubjectId() { return subjectId; } + public void setSubjectId(String subjectId) { this.subjectId = subjectId; } public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public String getName() { return name; } public String getTokenPrefix() { return tokenPrefix; } public String getTokenHash() { return tokenHash; } public String getScopeJson() { return scopeJson; } - public LocalDateTime getExpiresAt() { return expiresAt; } - public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } - public LocalDateTime getLastUsedAt() { return lastUsedAt; } - public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; } - public LocalDateTime getRevokedAt() { return revokedAt; } - public void setRevokedAt(LocalDateTime revokedAt) { this.revokedAt = revokedAt; } - public LocalDateTime getCreatedAt() { return createdAt; } + public Instant getExpiresAt() { return expiresAt; } + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } + public Instant getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(Instant lastUsedAt) { this.lastUsedAt = lastUsedAt; } + public Instant getRevokedAt() { return revokedAt; } + public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; } + public Instant getCreatedAt() { return createdAt; } public boolean isRevoked() { return revokedAt != null; } - public boolean isExpired() { return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); } + public boolean isExpired() { return isExpired(Instant.now(Clock.systemUTC())); } + public boolean isExpired(Instant referenceTime) { return expiresAt != null && expiresAt.isBefore(referenceTime); } public boolean isValid() { return !isRevoked() && !isExpired(); } + public boolean isValid(Instant referenceTime) { return !isRevoked() && !isExpired(referenceTime); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java index 36f5e9c0..163694f4 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "identity_binding", @@ -27,10 +28,10 @@ public class IdentityBinding { private String extraJson; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected IdentityBinding() {} @@ -43,13 +44,13 @@ public IdentityBinding(String userId, String providerCode, String subject, Strin @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -63,4 +64,6 @@ void preUpdate() { public void setLoginName(String loginName) { this.loginName = loginName; } public String getExtraJson() { return extraJson; } public void setExtraJson(String extraJson) { this.extraJson = extraJson; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java index 6c50924b..fbd3569a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "role") @@ -23,13 +24,14 @@ public class Role { private boolean system; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @PrePersist - void prePersist() { this.createdAt = LocalDateTime.now(); } + void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } public String getCode() { return code; } public String getName() { return name; } public boolean isSystem() { return system; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java index 3f7807ff..20951d03 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "user_role_binding", @@ -19,7 +20,7 @@ public class UserRoleBinding { private Role role; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected UserRoleBinding() {} @@ -29,9 +30,11 @@ public UserRoleBinding(String userId, Role role) { } @PrePersist - void prePersist() { this.createdAt = LocalDateTime.now(); } + void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public Role getRole() { return role; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java new file mode 100644 index 00000000..b2127c80 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.auth.exception; + +import org.springframework.http.HttpStatus; + +public class AuthFlowException extends RuntimeException { + + private final HttpStatus status; + private final String messageCode; + private final Object[] messageArgs; + + public AuthFlowException(HttpStatus status, String messageCode, Object... messageArgs) { + super(messageCode); + this.status = status; + this.messageCode = messageCode; + this.messageArgs = messageArgs; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessageCode() { + return messageCode; + } + + public Object[] getMessageArgs() { + return messageArgs; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 4227b326..d716d4f3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -3,8 +3,10 @@ import com.iflytek.skillhub.auth.entity.IdentityBinding; import com.iflytek.skillhub.auth.oauth.OAuthClaims; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -20,13 +22,16 @@ public class IdentityBindingService { private final IdentityBindingRepository bindingRepo; private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; public IdentityBindingService(IdentityBindingRepository bindingRepo, - UserAccountRepository userRepo, - UserRoleBindingRepository roleBindingRepo) { + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo, + GlobalNamespaceMembershipService globalNamespaceMembershipService) { this.bindingRepo = bindingRepo; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; } @Transactional @@ -54,6 +59,9 @@ public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStat ); user.setStatus(initialStatus); user = userRepo.save(user); + if (initialStatus == UserStatus.ACTIVE) { + globalNamespaceMembershipService.ensureMember(user.getId()); + } binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); bindingRepo.save(binding); @@ -69,6 +77,7 @@ public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStat Set roles = roleBindingRepo.findByUserId(user.getId()).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); return new PlatformPrincipal( user.getId(), user.getDisplayName(), user.getEmail(), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java new file mode 100644 index 00000000..4fd3fd99 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -0,0 +1,224 @@ +package com.iflytek.skillhub.auth.local; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LocalAuthService { + + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final int MAX_FAILED_ATTEMPTS = 5; + private static final Duration LOCK_DURATION = Duration.ofMinutes(15); + // Precomputed BCrypt hash for "skillhub-local-auth-dummy". Used to blur timing + // differences between existing and non-existing usernames during login. + private static final String DUMMY_PASSWORD_HASH = + "$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"; + + private final LocalCredentialRepository credentialRepository; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final PasswordPolicyValidator passwordPolicyValidator; + private final PasswordEncoder passwordEncoder; + private final Clock clock; + + public LocalAuthService(LocalCredentialRepository credentialRepository, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, + PasswordPolicyValidator passwordPolicyValidator, + PasswordEncoder passwordEncoder, + Clock clock) { + this.credentialRepository = credentialRepository; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.passwordPolicyValidator = passwordPolicyValidator; + this.passwordEncoder = passwordEncoder; + this.clock = clock; + } + + @Transactional + public PlatformPrincipal register(String username, String password, String email) { + String normalizedUsername = normalizeUsername(username); + validateUsername(normalizedUsername); + + if (credentialRepository.existsByUsernameIgnoreCase(normalizedUsername)) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.username.exists"); + } + + String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); + if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists"); + } + + var passwordErrors = passwordPolicyValidator.validate(password); + if (!passwordErrors.isEmpty()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst()); + } + + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + normalizedUsername, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + userAccountRepository.save(user); + + credentialRepository.save(new LocalCredential( + user.getId(), + normalizedUsername, + passwordEncoder.encode(password) + )); + globalNamespaceMembershipService.ensureMember(user.getId()); + + return buildPrincipal(user); + } + + @Transactional + public PlatformPrincipal login(String username, String password) { + String normalizedUsername = normalizeUsername(username); + LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) + .orElse(null); + + if (credential == null) { + passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + throw invalidCredentials(); + } + + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + + ensureUserCanLogin(user); + ensureNotLocked(credential); + + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } + + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + } + + @Transactional + public void changePassword(String userId, String currentPassword, String newPassword) { + LocalCredential credential = credentialRepository.findByUserId(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.notEnabled")); + + if (!passwordEncoder.matches(currentPassword, credential.getPasswordHash())) { + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"); + } + + var passwordErrors = passwordPolicyValidator.validate(newPassword); + if (!passwordErrors.isEmpty()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst()); + } + + credential.setPasswordHash(passwordEncoder.encode(newPassword)); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + } + + private PlatformPrincipal buildPrincipal(UserAccount user) { + Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + "local", + roles + ); + } + + private void ensureUserCanLogin(UserAccount user) { + if (user.getStatus() == UserStatus.DISABLED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); + } + if (user.getStatus() == UserStatus.PENDING) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); + } + if (user.getStatus() == UserStatus.MERGED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + } + } + + private void ensureNotLocked(LocalCredential credential) { + Instant now = currentTime(); + if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(now)) { + long minutes = Math.max(1, Duration.between(now, credential.getLockedUntil()).toMinutes()); + throw new AuthFlowException(HttpStatus.LOCKED, "error.auth.local.locked", minutes); + } + } + + private void handleFailedLogin(LocalCredential credential) { + int failedAttempts = credential.getFailedAttempts() + 1; + credential.setFailedAttempts(failedAttempts); + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + credential.setLockedUntil(currentTime().plus(LOCK_DURATION)); + } + credentialRepository.save(credential); + } + + private Instant currentTime() { + return Instant.now(clock); + } + + private AuthFlowException invalidCredentials() { + return new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"); + } + + private String normalizeUsername(String username) { + return username == null ? "" : username.trim().toLowerCase(Locale.ROOT); + } + + private String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + return null; + } + return email.trim().toLowerCase(Locale.ROOT); + } + + private void validateUsername(String username) { + if (!USERNAME_PATTERN.matcher(username).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid"); + } + } + + private void validateEmail(String email) { + if (email == null) { + return; + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java new file mode 100644 index 00000000..d712516f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.auth.local; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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; + +@Entity +@Table(name = "local_credential") +public class LocalCredential { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128, unique = true) + private String userId; + + @Column(nullable = false, length = 64, unique = true) + private String username; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "failed_attempts", nullable = false) + private int failedAttempts; + + @Column(name = "locked_until") + private Instant lockedUntil; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected LocalCredential() {} + + public LocalCredential(String userId, String username, String passwordHash) { + this.userId = userId; + this.username = username; + this.passwordHash = passwordHash; + this.failedAttempts = 0; + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = Instant.now(Clock.systemUTC()); + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public Instant getLockedUntil() { + return lockedUntil; + } + + public void setLockedUntil(Instant lockedUntil) { + this.lockedUntil = lockedUntil; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java new file mode 100644 index 00000000..ffb65668 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.local; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LocalCredentialRepository extends JpaRepository { + + Optional findByUsernameIgnoreCase(String username); + + Optional findByUserId(String userId); + + boolean existsByUsernameIgnoreCase(String username); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java new file mode 100644 index 00000000..1afcb86f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.local; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class PasswordPolicyValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 128; + private static final int MIN_CHAR_TYPES = 3; + + public List validate(String password) { + List errors = new ArrayList<>(); + if (password == null || password.length() < MIN_LENGTH) { + errors.add("error.auth.local.password.tooShort"); + return errors; + } + if (password.length() > MAX_LENGTH) { + errors.add("error.auth.local.password.tooLong"); + return errors; + } + + int typeCount = 0; + if (password.chars().anyMatch(Character::isLowerCase)) { + typeCount++; + } + if (password.chars().anyMatch(Character::isUpperCase)) { + typeCount++; + } + if (password.chars().anyMatch(Character::isDigit)) { + typeCount++; + } + if (password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch))) { + typeCount++; + } + if (typeCount < MIN_CHAR_TYPES) { + errors.add("error.auth.local.password.tooWeak"); + } + return errors; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java new file mode 100644 index 00000000..e6264107 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java @@ -0,0 +1,77 @@ +package com.iflytek.skillhub.auth.merge; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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; + +@Entity +@Table(name = "account_merge_request") +public class AccountMergeRequest { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_VERIFIED = "VERIFIED"; + public static final String STATUS_COMPLETED = "COMPLETED"; + public static final String STATUS_CANCELLED = "CANCELLED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "primary_user_id", nullable = false, length = 128) + private String primaryUserId; + + @Column(name = "secondary_user_id", nullable = false, length = 128) + private String secondaryUserId; + + @Column(nullable = false, length = 32) + private String status = STATUS_PENDING; + + @Column(name = "verification_token", length = 255) + private String verificationToken; + + @Column(name = "token_expires_at") + private Instant tokenExpiresAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected AccountMergeRequest() {} + + public AccountMergeRequest(String primaryUserId, + String secondaryUserId, + String verificationToken, + Instant tokenExpiresAt) { + this.primaryUserId = primaryUserId; + this.secondaryUserId = secondaryUserId; + this.verificationToken = verificationToken; + this.tokenExpiresAt = tokenExpiresAt; + this.status = STATUS_PENDING; + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + } + + public Long getId() { return id; } + public String getPrimaryUserId() { return primaryUserId; } + public String getSecondaryUserId() { return secondaryUserId; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getVerificationToken() { return verificationToken; } + public void setVerificationToken(String verificationToken) { this.verificationToken = verificationToken; } + public Instant getTokenExpiresAt() { return tokenExpiresAt; } + public void setTokenExpiresAt(Instant tokenExpiresAt) { this.tokenExpiresAt = tokenExpiresAt; } + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java new file mode 100644 index 00000000..8c98fb3c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.merge; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AccountMergeRequestRepository extends JpaRepository { + + Optional findByIdAndPrimaryUserId(Long id, String primaryUserId); + + boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java new file mode 100644 index 00000000..92368699 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -0,0 +1,284 @@ +package com.iflytek.skillhub.auth.merge; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountMergeService { + + private static final Comparator NAMESPACE_ROLE_ORDER = Comparator.comparingInt(role -> switch (role) { + case MEMBER -> 0; + case ADMIN -> 1; + case OWNER -> 2; + }); + + private final AccountMergeRequestRepository mergeRequestRepository; + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final IdentityBindingRepository identityBindingRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final ApiTokenRepository apiTokenRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + private final Clock clock; + private final SecureRandom secureRandom = new SecureRandom(); + + public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, + UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + IdentityBindingRepository identityBindingRepository, + UserRoleBindingRepository userRoleBindingRepository, + ApiTokenRepository apiTokenRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder, + Clock clock) { + this.mergeRequestRepository = mergeRequestRepository; + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.identityBindingRepository = identityBindingRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.apiTokenRepository = apiTokenRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + this.clock = clock; + } + + public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, Instant expiresAt) {} + + @Transactional + public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) { + UserAccount primaryUser = loadActiveUser(primaryUserId); + UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier); + validateMergePair(primaryUser, secondaryUser); + + if (mergeRequestRepository.existsBySecondaryUserIdAndStatus( + secondaryUser.getId(), + AccountMergeRequest.STATUS_PENDING + )) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists"); + } + + Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); + Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId()); + if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); + } + + String rawToken = generateVerificationToken(); + AccountMergeRequest request = new AccountMergeRequest( + primaryUserId, + secondaryUser.getId(), + passwordEncoder.encode(rawToken), + currentTime().plus(Duration.ofMinutes(30)) + ); + request = mergeRequestRepository.save(request); + return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt()); + } + + @Transactional + public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); + } + if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); + } + if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) { + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); + } + + loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(loadActiveUser(primaryUserId), secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + mergeRequestRepository.save(request); + } + + @Transactional + public void confirm(String primaryUserId, Long mergeRequestId) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified"); + } + + UserAccount primaryUser = loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(primaryUser, secondaryUser); + + migrateIdentityBindings(primaryUser.getId(), secondaryUser.getId()); + migrateApiTokens(primaryUser.getId(), secondaryUser.getId()); + migrateUserRoles(primaryUser.getId(), secondaryUser.getId()); + migrateNamespaceMemberships(primaryUser.getId(), secondaryUser.getId()); + migrateLocalCredential(primaryUser.getId(), secondaryUser.getId()); + + if ((primaryUser.getEmail() == null || primaryUser.getEmail().isBlank()) + && secondaryUser.getEmail() != null && !secondaryUser.getEmail().isBlank()) { + primaryUser.setEmail(secondaryUser.getEmail()); + } + userAccountRepository.save(primaryUser); + + secondaryUser.setStatus(UserStatus.MERGED); + secondaryUser.setMergedToUserId(primaryUser.getId()); + userAccountRepository.save(secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_COMPLETED); + request.setCompletedAt(currentTime()); + request.setVerificationToken(null); + mergeRequestRepository.save(request); + } + + private UserAccount resolveSecondaryUser(String identifier) { + String normalized = identifier == null ? "" : identifier.trim(); + if (normalized.isBlank()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierRequired"); + } + if (normalized.contains(":")) { + String[] parts = normalized.split(":", 2); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierInvalid"); + } + IdentityBinding binding = identityBindingRepository.findByProviderCodeAndSubject(parts[0], parts[1]) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + return userAccountRepository.findById(binding.getUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + } + + LocalCredential credential = localCredentialRepository.findByUsernameIgnoreCase(normalized.toLowerCase(Locale.ROOT)) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + return userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + } + + private UserAccount loadActiveUser(String userId) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.primaryNotFound")); + if (user.getStatus() != UserStatus.ACTIVE) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.primaryNotActive"); + } + return user; + } + + private void validateMergePair(UserAccount primaryUser, UserAccount secondaryUser) { + if (primaryUser.getId().equals(secondaryUser.getId())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.sameAccount"); + } + if (secondaryUser.getStatus() != UserStatus.ACTIVE) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.secondaryNotActive"); + } + } + + private void migrateIdentityBindings(String primaryUserId, String secondaryUserId) { + List bindings = identityBindingRepository.findByUserId(secondaryUserId); + for (IdentityBinding binding : bindings) { + binding.setUserId(primaryUserId); + } + identityBindingRepository.saveAll(bindings); + } + + private void migrateApiTokens(String primaryUserId, String secondaryUserId) { + List tokens = apiTokenRepository.findByUserId(secondaryUserId); + for (ApiToken token : tokens) { + token.setUserId(primaryUserId); + if ("USER".equals(token.getSubjectType())) { + token.setSubjectId(primaryUserId); + } + } + apiTokenRepository.saveAll(tokens); + } + + private void migrateUserRoles(String primaryUserId, String secondaryUserId) { + Set primaryRoleCodes = new HashSet<>(); + for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) { + primaryRoleCodes.add(binding.getRole().getCode()); + } + + List secondaryBindings = userRoleBindingRepository.findByUserId(secondaryUserId); + for (UserRoleBinding binding : secondaryBindings) { + Role role = binding.getRole(); + if (!primaryRoleCodes.contains(role.getCode())) { + userRoleBindingRepository.save(new UserRoleBinding(primaryUserId, role)); + primaryRoleCodes.add(role.getCode()); + } + } + userRoleBindingRepository.deleteAll(secondaryBindings); + } + + private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { + List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + for (NamespaceMember secondaryMembership : secondaryMemberships) { + Optional existingPrimaryMembership = namespaceMemberRepository + .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); + if (existingPrimaryMembership.isPresent()) { + NamespaceMember primaryMembership = existingPrimaryMembership.get(); + if (NAMESPACE_ROLE_ORDER.compare(secondaryMembership.getRole(), primaryMembership.getRole()) > 0) { + primaryMembership.setRole(secondaryMembership.getRole()); + namespaceMemberRepository.save(primaryMembership); + } + namespaceMemberRepository.deleteByNamespaceIdAndUserId( + secondaryMembership.getNamespaceId(), + secondaryUserId + ); + } else { + secondaryMembership.setUserId(primaryUserId); + namespaceMemberRepository.save(secondaryMembership); + } + } + } + + private void migrateLocalCredential(String primaryUserId, String secondaryUserId) { + Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); + Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUserId); + if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); + } + secondaryCredential.ifPresent(credential -> { + credential.setUserId(primaryUserId); + localCredentialRepository.save(credential); + }); + } + + private String generateVerificationToken() { + byte[] tokenBytes = new byte[24]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } + + private Instant currentTime() { + return Instant.now(clock); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java index 30004856..a0ab03ae 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.auth.mock; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import jakarta.servlet.FilterChain; @@ -9,9 +11,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -22,16 +23,20 @@ @Component @Profile("local") +@ConditionalOnProperty(name = "skillhub.auth.mock.enabled", havingValue = "true") @Order(-100) public class MockAuthFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; + private final PlatformSessionService platformSessionService; public MockAuthFilter(UserAccountRepository userRepo, - UserRoleBindingRepository roleBindingRepo) { + UserRoleBindingRepository roleBindingRepo, + PlatformSessionService platformSessionService) { this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; + this.platformSessionService = platformSessionService; } @Override @@ -46,16 +51,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Set roles = roleBindingRepo.findByUserId(userId).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); var principal = new PlatformPrincipal( user.getId(), user.getDisplayName(), user.getEmail(), user.getAvatarUrl(), "mock", roles ); - var authorities = roles.stream() - .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) - .toList(); - var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - request.getSession().setAttribute("platformPrincipal", principal); + platformSessionService.establishSession(principal, request, false); }); } filterChain.doFilter(request, response); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 71b90d01..4f18a23a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -3,11 +3,14 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { @@ -16,6 +19,7 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + String returnTo = consumeReturnTo(request.getSession(false)); if (exception instanceof AccountPendingException) { getRedirectStrategy().sendRedirect(request, response, "/pending-approval"); return; @@ -30,6 +34,24 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo return; } + if (returnTo != null) { + getRedirectStrategy().sendRedirect( + request, + response, + "/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8) + ); + return; + } + super.onAuthenticationFailure(request, response, exception); } + + private String consumeReturnTo(HttpSession session) { + if (session == null) { + return null; + } + Object value = session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + return value instanceof String str ? OAuthLoginRedirectSupport.sanitizeReturnTo(str) : null; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index 083c9966..d0a1d1b2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -1,9 +1,11 @@ package com.iflytek.skillhub.auth.oauth; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; @@ -14,8 +16,11 @@ @Component public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { - public OAuth2LoginSuccessHandler() { - setDefaultTargetUrl("/"); + private final PlatformSessionService platformSessionService; + + public OAuth2LoginSuccessHandler(PlatformSessionService platformSessionService) { + this.platformSessionService = platformSessionService; + setDefaultTargetUrl(OAuthLoginRedirectSupport.DEFAULT_TARGET_URL); } @Override @@ -24,9 +29,24 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) { PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal"); if (principal != null) { - request.getSession().setAttribute("platformPrincipal", principal); + platformSessionService.attachToAuthenticatedSession(principal, authentication, request, true); } } + String returnTo = consumeReturnTo(request.getSession(false)); + if (returnTo != null) { + getRedirectStrategy().sendRedirect(request, response, returnTo); + clearAuthenticationAttributes(request); + return; + } super.onAuthenticationSuccess(request, response, authentication); } + + private String consumeReturnTo(HttpSession session) { + if (session == null) { + return null; + } + Object value = session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + return value instanceof String str ? OAuthLoginRedirectSupport.sanitizeReturnTo(str) : null; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java new file mode 100644 index 00000000..6d1fd77f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.oauth; + +public final class OAuthLoginRedirectSupport { + + public static final String SESSION_RETURN_TO_ATTRIBUTE = "skillhub.oauth.returnTo"; + public static final String DEFAULT_TARGET_URL = "/dashboard"; + + private OAuthLoginRedirectSupport() { + } + + public static String sanitizeReturnTo(String candidate) { + if (candidate == null || candidate.isBlank()) { + return null; + } + String trimmed = candidate.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return null; + } + if (trimmed.contains("\r") || trimmed.contains("\n")) { + return null; + } + return trimmed; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..6b42f593 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.auth.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +@Component +public class SkillHubOAuth2AuthorizationRequestResolver + implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver { + + private final DefaultOAuth2AuthorizationRequestResolver delegate; + + public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) { + this.delegate = new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + "/oauth2/authorization" + ); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); + rememberReturnTo(request); + return authorizationRequest; + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); + rememberReturnTo(request); + return authorizationRequest; + } + + private void rememberReturnTo(HttpServletRequest request) { + String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo")); + if (returnTo == null) { + request.getSession().removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + return; + } + request.getSession().setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, returnTo); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java new file mode 100644 index 00000000..2ccfedee --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.rbac; + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + +public final class PlatformRoleDefaults { + + public static final String DEFAULT_USER_ROLE = "USER"; + + private PlatformRoleDefaults() { + } + + public static Set withDefaultUserRole(Collection roles) { + Set resolvedRoles = new TreeSet<>(); + if (roles != null) { + resolvedRoles.addAll(roles); + } + if (resolvedRoles.isEmpty()) { + resolvedRoles.add(DEFAULT_USER_ROLE); + } + return Set.copyOf(resolvedRoles); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java index 3bd9592a..8c94e2ee 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java @@ -23,9 +23,9 @@ public RbacService(UserRoleBindingRepository roleBindingRepo, EntityManager enti } public Set getUserRoleCodes(String userId) { - return roleBindingRepo.findByUserId(userId).stream() + return PlatformRoleDefaults.withDefaultUserRole(roleBindingRepo.findByUserId(userId).stream() .map(rb -> rb.getRole().getCode()) - .collect(Collectors.toSet()); + .collect(Collectors.toSet())); } public Set getUserPermissions(String userId) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index dd9afb7f..765c6946 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.ApiToken; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -9,5 +11,9 @@ @Repository public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); + List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); + Page findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable); + boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); + Optional findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(String userId, String name); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java index efad961f..12f48f8f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java @@ -8,4 +8,5 @@ @Repository public interface IdentityBindingRepository extends JpaRepository { Optional findByProviderCodeAndSubject(String providerCode, String subject); + java.util.List findByUserId(String userId); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java index def4ad3b..84e5bd37 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -11,5 +11,5 @@ public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); List findByUserIdIn(Collection userIds); - void deleteByUserId(String userId); + long deleteByUserId(String userId); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java new file mode 100644 index 00000000..01332a6d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.auth.session; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.stereotype.Service; + +@Service +public class PlatformSessionService { + + public void establishSession(PlatformPrincipal principal, HttpServletRequest request) { + establishSession(principal, request, true); + } + + public void establishSession(PlatformPrincipal principal, + HttpServletRequest request, + boolean rotateSessionId) { + var authorities = principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + persist(principal, authentication, request, rotateSessionId); + } + + public void attachToAuthenticatedSession(PlatformPrincipal principal, + Authentication authentication, + HttpServletRequest request) { + attachToAuthenticatedSession(principal, authentication, request, false); + } + + public void attachToAuthenticatedSession(PlatformPrincipal principal, + Authentication authentication, + HttpServletRequest request, + boolean rotateSessionId) { + persist(principal, authentication, request, rotateSessionId); + } + + private void persist(PlatformPrincipal principal, + Authentication authentication, + HttpServletRequest request, + boolean rotateSessionId) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + request.getSession(true); + if (rotateSessionId) { + request.changeSessionId(); + } + request.getSession().setAttribute("platformPrincipal", principal); + request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 01632e9b..3b227842 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -57,6 +58,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Set roles = roleBindingRepo.findByUserId(user.getId()).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); Set scopes = apiTokenScopeService.parseScopes(token.getScopeJson()); PlatformPrincipal principal = new PlatformPrincipal( user.getId(), user.getDisplayName(), user.getEmail(), @@ -82,6 +84,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); return !(path.startsWith("/api/v1/") - || path.startsWith("/api/compat/")); + || path.startsWith("/api/web/")); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index c4ff48a5..aea4dfe0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -66,7 +66,8 @@ protected void doFilterInternal(HttpServletRequest request, @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - return path == null || (!path.startsWith("/api/v1/") && !path.startsWith("/api/compat/")); + return path == null || (!path.startsWith("/api/v1/") + && !path.startsWith("/api/web/")); } private boolean isApiTokenAuthentication(Authentication authentication) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java index 09445f69..69c569c2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java @@ -19,16 +19,22 @@ public class ApiTokenScopeService { ScopeRule.allow(null, "/api/v1/health"), ScopeRule.allow(null, "/api/v1/auth/providers"), ScopeRule.allow(null, "/api/v1/auth/me"), - ScopeRule.allow(null, "/api/v1/cli/auth/device/**"), - ScopeRule.allow(null, "/api/v1/cli/check"), - ScopeRule.allow("GET", "/api/v1/cli/whoami"), + ScopeRule.allow(null, "/api/v1/auth/device/**"), + ScopeRule.allow(null, "/api/v1/check"), + ScopeRule.allow("GET", "/api/v1/whoami"), + ScopeRule.allow("GET", "/api/v1/search"), ScopeRule.allow("GET", "/api/v1/skills"), ScopeRule.allow("GET", "/api/v1/skills/**"), + ScopeRule.allow("GET", "/api/web/skills"), + ScopeRule.allow("GET", "/api/web/skills/**"), ScopeRule.allow("GET", "/api/v1/namespaces"), ScopeRule.allow("GET", "/api/v1/namespaces/*"), - ScopeRule.allow("GET", "/api/compat/v1/search"), - ScopeRule.allow("GET", "/api/compat/v1/resolve/**"), - ScopeRule.allow("GET", "/api/compat/v1/whoami"), + ScopeRule.allow("GET", "/api/web/namespaces"), + ScopeRule.allow("GET", "/api/web/namespaces/*"), + ScopeRule.allow("GET", "/api/v1/search"), + ScopeRule.allow("GET", "/api/v1/resolve/**"), + ScopeRule.allow("GET", "/api/v1/whoami"), + ScopeRule.allow("GET", "/api/v1/download"), ScopeRule.allow(null, "/.well-known/**"), ScopeRule.allow(null, "/actuator/health"), ScopeRule.allow(null, "/v3/api-docs/**"), @@ -38,9 +44,11 @@ public class ApiTokenScopeService { private static final List REQUIRED_SCOPE_RULES = List.of( ScopeRule.require(null, "/api/v1/tokens", "token:manage"), ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"), + ScopeRule.require("POST", "/api/v1/skills", "skill:publish"), ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"), - ScopeRule.require("POST", "/api/v1/cli/publish", "skill:publish"), - ScopeRule.require("POST", "/api/compat/v1/publish", "skill:publish") + ScopeRule.require("POST", "/api/web/skills/*/publish", "skill:publish"), + ScopeRule.require("POST", "/api/v1/publish", "skill:publish"), + ScopeRule.require("POST", "/api/v1/publish", "skill:publish") ); private final ObjectMapper objectMapper; @@ -96,7 +104,7 @@ public AuthorizationDecision authorize(String method, String path, Set t } private boolean isApiPath(String path) { - return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/compat/")); + return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/")); } public record AuthorizationDecision(boolean allowed, String requiredScope, String message) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index f51b60a0..6ffcaa23 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -2,6 +2,11 @@ import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -9,7 +14,12 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.util.Base64; import java.util.HexFormat; import java.util.List; @@ -20,31 +30,68 @@ public class ApiTokenService { private static final String TOKEN_PREFIX = "sk_"; private static final int TOKEN_BYTES = 32; + private static final int MAX_NAME_LENGTH = 64; private final SecureRandom secureRandom = new SecureRandom(); private final ApiTokenRepository tokenRepo; + private final Clock clock; - public ApiTokenService(ApiTokenRepository tokenRepo) { + public ApiTokenService(ApiTokenRepository tokenRepo, Clock clock) { this.tokenRepo = tokenRepo; + this.clock = clock; } public record TokenCreateResult(String rawToken, ApiToken entity) {} @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson) { + return createToken(userId, name, scopeJson, null); + } + + @Transactional + public TokenCreateResult createToken(String userId, String name, String scopeJson, String expiresAt) { + String normalizedName = normalizeName(name); + validateTokenName(userId, normalizedName); + Instant parsedExpiresAt = parseExpiresAt(expiresAt); + byte[] randomBytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(randomBytes); String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); String tokenHash = sha256(rawToken); String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8)); - ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson); - token = tokenRepo.save(token); + ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson); + token.setExpiresAt(parsedExpiresAt); + try { + token = tokenRepo.save(token); + } catch (DataIntegrityViolationException ex) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } return new TokenCreateResult(rawToken, token); } + /** + * Revoke existing token with the same name (if any) and create a new one. + * Used by device auth flow to avoid duplicate-name errors on repeated logins. + */ + @Transactional + public TokenCreateResult rotateToken(String userId, String name, String scopeJson) { + return rotateToken(userId, name, scopeJson, null); + } + + @Transactional + public TokenCreateResult rotateToken(String userId, String name, String scopeJson, String expiresAt) { + String normalizedName = normalizeName(name); + tokenRepo.findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(userId, normalizedName) + .ifPresent(existing -> { + existing.setRevokedAt(currentTime()); + tokenRepo.save(existing); + }); + return createToken(userId, name, scopeJson, expiresAt); + } + public Optional validateToken(String rawToken) { String hash = sha256(rawToken); - return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid); + return tokenRepo.findByTokenHash(hash).filter(token -> token.isValid(currentTime())); } @Transactional @@ -52,18 +99,33 @@ public void revokeToken(Long tokenId, String userId) { tokenRepo.findById(tokenId) .filter(t -> t.getUserId().equals(userId)) .ifPresent(t -> { - t.setRevokedAt(LocalDateTime.now()); + t.setRevokedAt(currentTime()); tokenRepo.save(t); }); } + @Transactional + public ApiToken updateExpiration(Long tokenId, String userId, String expiresAt) { + ApiToken token = tokenRepo.findById(tokenId) + .filter(existing -> existing.getUserId().equals(userId) && existing.getRevokedAt() == null) + .orElseThrow(() -> new DomainNotFoundException("error.token.notFound", tokenId)); + token.setExpiresAt(parseExpiresAt(expiresAt)); + return tokenRepo.save(token); + } + public List listActiveTokens(String userId) { return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId); } + public Page listActiveTokens(String userId, int page, int size) { + int resolvedPage = Math.max(page, 0); + int resolvedSize = Math.max(size, 1); + return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId, PageRequest.of(resolvedPage, resolvedSize)); + } + @Transactional public void touchLastUsed(ApiToken token) { - token.setLastUsedAt(LocalDateTime.now()); + token.setLastUsedAt(currentTime()); tokenRepo.save(token); } @@ -76,4 +138,58 @@ private String sha256(String input) { throw new RuntimeException("SHA-256 not available", e); } } + + private String normalizeName(String name) { + if (name == null) { + return ""; + } + return name.trim(); + } + + private void validateTokenName(String userId, String name) { + if (name.isBlank()) { + throw new DomainBadRequestException("validation.token.name.notBlank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("validation.token.name.size"); + } + if (tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(userId, name)) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } + } + + private Instant parseExpiresAt(String expiresAt) { + if (expiresAt == null || expiresAt.isBlank()) { + return null; + } + + try { + Instant parsed = parseInstant(expiresAt.trim()); + if (!parsed.isAfter(currentTime())) { + throw new DomainBadRequestException("validation.token.expiresAt.future"); + } + return parsed; + } catch (DateTimeParseException ex) { + throw new DomainBadRequestException("validation.token.expiresAt.invalid"); + } + } + + private Instant parseInstant(String value) { + try { + return Instant.parse(value); + } catch (DateTimeParseException ignored) { + } + + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (DateTimeParseException ignored) { + } + + // Legacy compatibility: treat naive timestamps as UTC instead of server-local time. + return LocalDateTime.parse(value).toInstant(ZoneOffset.UTC); + } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java deleted file mode 100644 index ac15521a..00000000 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.iflytek.skillhub.auth.device; - -import com.iflytek.skillhub.auth.entity.ApiToken; -import com.iflytek.skillhub.auth.token.ApiTokenService; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -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; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeviceAuthServiceTest { - - @Mock - private RedisTemplate redisTemplate; - - @Mock - private ValueOperations valueOperations; - - @Mock - private ApiTokenService apiTokenService; - - private DeviceAuthService service; - - @BeforeEach - void setUp() { - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device"); - } - - @Test - void generateDeviceCode_returns_valid_response() { - // When - DeviceCodeResponse response = service.generateDeviceCode(); - - // Then - assertThat(response.deviceCode()).isNotEmpty(); - assertThat(response.userCode()).matches("[A-Z2-9]{4}-[A-Z2-9]{4}"); - assertThat(response.verificationUri()).isEqualTo("https://skillhub.example.com/device"); - assertThat(response.expiresIn()).isEqualTo(900); // 15 minutes - assertThat(response.interval()).isEqualTo(5); - - // Verify Redis storage - ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(Object.class); - verify(valueOperations, times(2)).set(keyCaptor.capture(), valueCaptor.capture(), eq(15L), eq(TimeUnit.MINUTES)); - - // Verify device code key and data - assertThat(keyCaptor.getAllValues().get(0)).startsWith("device:code:"); - DeviceCodeData data = (DeviceCodeData) valueCaptor.getAllValues().get(0); - assertThat(data.getDeviceCode()).isEqualTo(response.deviceCode()); - assertThat(data.getUserCode()).isEqualTo(response.userCode()); - assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.PENDING); - - // Verify user code key and value - assertThat(keyCaptor.getAllValues().get(1)).startsWith("device:usercode:"); - assertThat(valueCaptor.getAllValues().get(1)).isEqualTo(response.deviceCode()); - } - - @Test - void pollToken_returns_pending_when_not_authorized() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null); - when(valueOperations.get("device:code:device123")).thenReturn(data); - - // When - DeviceTokenResponse response = service.pollToken("device123"); - - // Then - assertThat(response.error()).isEqualTo("authorization_pending"); - assertThat(response.accessToken()).isNull(); - } - - @Test - void pollToken_returns_access_token_when_authorized() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(true); - when(apiTokenService.createToken("42", "CLI Device Flow", "[\"skill:read\",\"skill:publish\"]")) - .thenReturn(new ApiTokenService.TokenCreateResult("sk_cli_token", mock(ApiToken.class))); - - // When - DeviceTokenResponse response = service.pollToken("device123"); - - // Then - assertThat(response.accessToken()).isEqualTo("sk_cli_token"); - assertThat(response.tokenType()).isEqualTo("Bearer"); - assertThat(response.error()).isNull(); - assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.USED); - verify(valueOperations).set("device:code:device123", data, 1L, TimeUnit.MINUTES); - verify(redisTemplate).delete("device:usercode:ABCD-1234"); - } - - @Test - void pollToken_rejects_second_exchange_attempt() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(false); - - // When / Then - assertThatThrownBy(() -> service.pollToken("device123")) - .isInstanceOf(DomainBadRequestException.class) - .hasMessageContaining("error.deviceAuth.deviceCode.used"); - verify(apiTokenService, never()).createToken(anyString(), anyString(), anyString()); - } - - @Test - void pollToken_returns_error_when_expired() { - // Given - when(valueOperations.get("device:code:expired123")).thenReturn(null); - - // When / Then - assertThatThrownBy(() -> service.pollToken("expired123")) - .isInstanceOf(DomainBadRequestException.class) - .hasMessageContaining("error.deviceAuth.deviceCode.invalid"); - } - - @Test - void authorizeDeviceCode_updates_status() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null); - when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - - // When - service.authorizeDeviceCode("ABCD-1234", "42"); - - // Then - assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED); - assertThat(data.getUserId()).isEqualTo("42"); - verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES)); - } - - @Test - void authorizeDeviceCode_rejects_different_user_after_authorization() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); - when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - - // When / Then - assertThatThrownBy(() -> service.authorizeDeviceCode("ABCD-1234", "99")) - .isInstanceOf(DomainBadRequestException.class) - .hasMessageContaining("error.deviceAuth.deviceCode.alreadyAuthorized"); - } -} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java new file mode 100644 index 00000000..79e5df74 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -0,0 +1,182 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.oauth.AccountDisabledException; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import com.iflytek.skillhub.auth.oauth.AccountPendingException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class IdentityBindingServiceTest { + + @Mock + private IdentityBindingRepository bindingRepo; + + @Mock + private UserAccountRepository userRepo; + + @Mock + private UserRoleBindingRepository roleBindingRepo; + + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + private IdentityBindingService service; + + @BeforeEach + void setUp() { + service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService); + } + + @Test + void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of("avatar_url", "https://example.test/a.png") + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userRepo).save(userCaptor.capture()); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(bindingRepo).save(any(IdentityBinding.class)); + assertThat(principal.displayName()).isEqualTo("alice"); + assertThat(principal.oauthProvider()).isEqualTo("github"); + } + + @Test + void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING)) + .isInstanceOf(AccountPendingException.class); + + verify(globalNamespaceMembershipService, never()).ensureMember(any()); + } + + @Test + void bindOrCreate_defaultsToUserRoleWhenNoBindingsExist() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); + + assertThat(principal.platformRoles()).containsExactly("USER"); + } + + @Test + void bindOrCreate_existingDisabledUser_throwsAccountDisabled() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); + when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.ACTIVE)) + .isInstanceOf(AccountDisabledException.class); + } + + @Test + void bindOrCreate_returnsExplicitPlatformRolesWhenBindingsExist() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Role role = new Role(); + ReflectionTestUtils.setField(role, "code", "AUDITOR"); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of(new UserRoleBinding("usr_1", role))); + + PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); + + assertThat(principal.platformRoles()).containsExactly("AUDITOR"); + } + + @Test + void createPendingUserIfAbsent_existingDisabledBinding_throwsAccountDisabled() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); + when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.createPendingUserIfAbsent(claims)) + .isInstanceOf(AccountDisabledException.class); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java new file mode 100644 index 00000000..b566b922 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -0,0 +1,241 @@ +package com.iflytek.skillhub.auth.local; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +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; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class LocalAuthServiceTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T06:00:00Z"), ZoneOffset.UTC); + + @Mock + private LocalCredentialRepository credentialRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + @Mock + private UserRoleBindingRepository userRoleBindingRepository; + + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + @Mock + private PasswordEncoder passwordEncoder; + + private LocalAuthService service; + + @BeforeEach + void setUp() { + service = new LocalAuthService( + credentialRepository, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + new PasswordPolicyValidator(), + passwordEncoder, + CLOCK + ); + } + + @Test + void register_createsUserAndCredential() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty()); + given(passwordEncoder.encode("Abcd123!")).willReturn("encoded"); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of()); + + var principal = service.register("Alice", "Abcd123!", "alice@example.com"); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getDisplayName()).isEqualTo("alice"); + assertThat(principal.displayName()).isEqualTo("alice"); + assertThat(principal.email()).isEqualTo("alice@example.com"); + assertThat(principal.platformRoles()).containsExactly("USER"); + verify(credentialRepository).save(any(LocalCredential.class)); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + } + + @Test + void login_withValidPassword_resetsCounters() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + credential.setFailedAttempts(3); + credential.setLockedUntil(Instant.now(CLOCK).minusSeconds(60)); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + Role role = mock(Role.class); + given(role.getCode()).willReturn("USER_ADMIN"); + UserRoleBinding binding = new UserRoleBinding("usr_1", role); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true); + given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding)); + + var principal = service.login("alice", "Abcd123!"); + + assertThat(credential.getFailedAttempts()).isZero(); + assertThat(credential.getLockedUntil()).isNull(); + assertThat(principal.platformRoles()).containsExactly("USER_ADMIN"); + } + + @Test + void login_withInvalidPassword_incrementsCounter() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("bad", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.login("alice", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + assertThat(credential.getFailedAttempts()).isEqualTo(1); + verify(credentialRepository).save(credential); + } + + @Test + void login_afterMaxFailures_setsLockUsingInjectedClock() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + credential.setFailedAttempts(4); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("bad", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.login("alice", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + assertThat(credential.getLockedUntil()).isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60)); + } + + @Test + void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + credential.setLockedUntil(Instant.now(CLOCK).plusSeconds(5 * 60)); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("alice", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.locked"); + } + + @Test + void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { + given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) + .willReturn(false); + + assertThatThrownBy(() -> service.login("ghost", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(passwordEncoder).matches("bad", "$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"); + verify(userAccountRepository, never()).findById(any()); + } + + @Test + void login_withDisabledAccount_fails() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("alice", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.accountDisabled"); + } + + @Test + void login_withPendingAccount_fails() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.PENDING); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("alice", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.accountPending"); + } + + @Test + void login_withMergedAccount_fails() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.MERGED); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("alice", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.accountMerged"); + } + + @Test + void login_withoutExplicitRoles_defaultsToUser() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true); + given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of()); + + var principal = service.login("alice", "Abcd123!"); + + assertThat(principal.platformRoles()).containsExactly("USER"); + } + + @Test + void register_rejectsInvalidEmailFormat() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + + assertThatThrownBy(() -> service.register("Alice", "Abcd123!", "not-an-email")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("validation.auth.local.email.invalid"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java new file mode 100644 index 00000000..1b5eb8b7 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.auth.local; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PasswordPolicyValidatorTest { + + private final PasswordPolicyValidator validator = new PasswordPolicyValidator(); + + @Test + void validPassword_passes() { + assertThat(validator.validate("Abcdef1!")).isEmpty(); + } + + @Test + void tooShort_fails() { + assertThat(validator.validate("Ab1!xyz")).containsExactly("error.auth.local.password.tooShort"); + } + + @Test + void tooLong_fails() { + assertThat(validator.validate("A".repeat(129))).containsExactly("error.auth.local.password.tooLong"); + } + + @Test + void twoCharTypes_fails() { + assertThat(validator.validate("abcdefgh1")).containsExactly("error.auth.local.password.tooWeak"); + } + + @ParameterizedTest + @ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"}) + void threeCharTypes_pass(String password) { + assertThat(validator.validate(password)).isEmpty(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java new file mode 100644 index 00000000..d882386d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -0,0 +1,185 @@ +package com.iflytek.skillhub.auth.merge; + +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +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 org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class AccountMergeServiceTest { + + @Mock + private AccountMergeRequestRepository mergeRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; + @Mock + private LocalCredentialRepository localCredentialRepository; + @Mock + private IdentityBindingRepository identityBindingRepository; + @Mock + private UserRoleBindingRepository userRoleBindingRepository; + @Mock + private ApiTokenRepository apiTokenRepository; + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private PasswordEncoder passwordEncoder; + + private AccountMergeService service; + private Clock clock; + + @BeforeEach + void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + service = new AccountMergeService( + mergeRequestRepository, + userAccountRepository, + localCredentialRepository, + identityBindingRepository, + userRoleBindingRepository, + apiTokenRepository, + namespaceMemberRepository, + passwordEncoder, + clock + ); + } + + @Test + void initiate_withLocalUsername_createsPendingRequest() { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "secondary@example.com", null); + LocalCredential secondaryCredential = new LocalCredential("usr_secondary", "secondary", "hash"); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) + .willReturn(false); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential)); + given(passwordEncoder.encode(any())).willReturn("encoded-token"); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + var result = service.initiate("usr_primary", "secondary"); + + assertThat(result.secondaryUserId()).isEqualTo("usr_secondary"); + assertThat(result.verificationToken()).isNotBlank(); + assertThat(result.expiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z")); + verify(mergeRequestRepository).save(any(AccountMergeRequest.class)); + } + + @Test + void verify_marksRequestVerifiedWhenTokenMatches() throws Exception { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + service.verify("usr_primary", 7L, "raw-token"); + + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED); + verify(mergeRequestRepository).save(request); + } + + @Test + void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + Role role = mock(Role.class); + given(role.getCode()).willReturn("AUDITOR"); + UserRoleBinding secondaryRole = new UserRoleBinding("usr_secondary", role); + IdentityBinding binding = new IdentityBinding("usr_secondary", "github", "gh_123", "secondary"); + ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]"); + NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN); + + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding)); + given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token)); + given(userRoleBindingRepository.findByUserId("usr_primary")).willReturn(List.of()); + given(userRoleBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryRole)); + given(namespaceMemberRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryMembership)); + given(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty()); + + service.confirm("usr_primary", 7L); + + assertThat(binding.getUserId()).isEqualTo("usr_primary"); + assertThat(token.getUserId()).isEqualTo("usr_primary"); + assertThat(token.getSubjectId()).isEqualTo("usr_primary"); + assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary"); + assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED); + assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary"); + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_COMPLETED); + assertThat(request.getCompletedAt()).isEqualTo(Instant.parse("2026-03-18T00:00:00Z")); + assertThat(request.getVerificationToken()).isNull(); + verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); + verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); + } + + @Test + void verify_rejectsInvalidToken() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.invalidToken"); + + verify(identityBindingRepository, never()).saveAll(any()); + } + + private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception { + AccountMergeRequest request = new AccountMergeRequest( + primaryUserId, + secondaryUserId, + token, + Instant.now(clock).plusSeconds(600) + ); + Field idField = AccountMergeRequest.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, 7L); + return request; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java new file mode 100644 index 00000000..9e8a0a4d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.auth.oauth; + +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuth2AuthorizationRequestResolverTest { + + private SkillHubOAuth2AuthorizationRequestResolver resolver; + + @BeforeEach + void setUp() { + ClientRegistration github = ClientRegistration.withRegistrationId("github") + .clientId("client") + .clientSecret("secret") + .authorizationUri("https://example.test/oauth/authorize") + .tokenUri("https://example.test/oauth/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://example.test/user") + .userNameAttributeName("id") + .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) + .scope("read:user") + .clientName("GitHub") + .build(); + resolver = new SkillHubOAuth2AuthorizationRequestResolver(new InMemoryClientRegistrationRepository(github)); + } + + @Test + void resolve_storesSanitizedReturnToInSession() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + request.setParameter("returnTo", "/dashboard/publish?draft=1"); + + resolver.resolve(request, "github"); + + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isEqualTo("/dashboard/publish?draft=1"); + } + + @Test + void resolve_ignoresUnsafeReturnTo() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + request.setParameter("returnTo", "https://evil.example"); + + resolver.resolve(request, "github"); + + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java new file mode 100644 index 00000000..6b0956f7 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.auth.oauth; + +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuth2LoginHandlersTest { + + @Test + void successHandler_redirectsToStoredReturnTo() throws Exception { + OAuth2LoginSuccessHandler handler = new OAuth2LoginSuccessHandler(new com.iflytek.skillhub.auth.session.PlatformSessionService()); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + String originalSessionId = session.getId(); + session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/dashboard/publish"); + + var principal = new com.iflytek.skillhub.auth.rbac.PlatformPrincipal( + "user-1", "User", "user@example.com", null, "github", Set.of() + ); + Authentication authentication = new UsernamePasswordAuthenticationToken( + new DefaultOAuth2User(List.of(), Map.of("platformPrincipal", principal, "login", "user"), "login"), + null, + List.of() + ); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish"); + assertThat(request.getSession(false).getId()).isNotEqualTo(originalSessionId); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); + assertThat(session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNotNull(); + } + + @Test + void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception { + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/settings/accounts"); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error("invalid_request")) + ); + + assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts"); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index a981ffd1..f40e542c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -58,7 +58,7 @@ void shouldPopulateRoleAndScopeAuthoritiesForActiveUser() throws Exception { when(role.getCode()).thenReturn("SKILL_ADMIN"); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/api/v1/cli/whoami"); + request.setRequestURI("/api/v1/whoami"); request.addHeader("Authorization", "Bearer raw-token"); filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); @@ -84,7 +84,7 @@ void shouldRejectDisabledUsers() throws Exception { when(userAccountRepository.findById("user-2")).thenReturn(Optional.of(user)); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/api/v1/cli/publish"); + request.setRequestURI("/api/v1/publish"); request.addHeader("Authorization", "Bearer raw-token"); filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); @@ -92,4 +92,23 @@ void shouldRejectDisabledUsers() throws Exception { assertNull(SecurityContextHolder.getContext().getAuthentication()); verify(apiTokenService, never()).touchLastUsed(token); } + + @Test + void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception { + ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]"); + UserAccount user = new UserAccount("user-3", "Carol", "carol@example.com", ""); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-3")).thenReturn(Optional.of(user)); + when(roleBindingRepository.findByUserId("user-3")).thenReturn(List.of()); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/web/skills/global/publish"); + request.addHeader("Authorization", "Bearer raw-token"); + + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + verify(apiTokenService).touchLastUsed(token); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index fd50db75..735fddfc 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -59,7 +59,7 @@ void shouldDenyApiTokenWithoutRequiredScope() throws Exception { ); SecurityContextHolder.getContext().setAuthentication(authentication); - MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/cli/publish"); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); @@ -99,4 +99,37 @@ void shouldAllowSessionAuthRequestsWithoutScopeChecks() throws Exception { verify(chain).doFilter(request, response); verify(handler, never()).handle(eq(request), eq(response), any()); } + + @Test + void shouldDenyApiWebRequestsWithoutRequiredScope() throws Exception { + AccessDeniedHandler handler = (request, response, accessDeniedException) -> { + response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); + }; + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-3", + "Bob", + "bob@example.com", + "", + "api_token", + Set.of("USER") + ); + var authentication = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/web/skills/global/publish"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish")); + verify(chain, never()).doFilter(request, response); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java index 70c73b2d..002ec8a7 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java @@ -24,7 +24,7 @@ void parseScopesShouldNormalizeJsonArray() { void authorizeShouldAllowCliWhoamiWithoutScope() { ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize( "GET", - "/api/v1/cli/whoami", + "/api/v1/whoami", Set.of() ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java new file mode 100644 index 00000000..2e4de008 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.auth.token; + +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +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 org.springframework.dao.DataIntegrityViolationException; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApiTokenServiceTest { + + @Mock + private ApiTokenRepository tokenRepo; + + private ApiTokenService service; + private Clock clock; + + @BeforeEach + void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + service = new ApiTokenService(tokenRepo, clock); + } + + @Test + void createToken_rejectsNamesLongerThan64Characters() { + String longName = "a".repeat(65); + + assertThatThrownBy(() -> service.createToken("user-1", longName, "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.name.size"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_rejectsDuplicateActiveNamesIgnoringCase() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.token.name.duplicate"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_trimsNameBeforeCheckingDuplicates() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class); + + verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"); + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_setsExpirationWhenProvided() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI")) + .thenReturn(false); + when(tokenRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var result = service.createToken("user-1", "CLI", "[]", "2099-03-20T10:15:00"); + + assertThat(result.entity().getExpiresAt()).isEqualTo(Instant.parse("2099-03-20T10:15:00Z")); + } + + @Test + void createToken_rejectsPastExpiration() { + assertThatThrownBy(() -> service.createToken("user-1", "CLI", "[]", "2000-01-01T00:00:00")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.expiresAt.future"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_rejectsBlankNamesAfterTrimming() { + assertThatThrownBy(() -> service.createToken("user-1", " ", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.name.notBlank"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_allowsReusingNameWhenPreviousTokenIsRevoked() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI")) + .thenReturn(false); + when(tokenRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var result = service.createToken("user-1", " CLI ", "[]"); + + assertThat(result.entity().getName()).isEqualTo("CLI"); + verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI"); + verify(tokenRepo).save(any(ApiToken.class)); + } + + @Test + void createToken_translatesDatabaseConstraintViolationToDuplicateError() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI")) + .thenReturn(false); + when(tokenRepo.save(any())).thenThrow(new DataIntegrityViolationException("duplicate key")); + + assertThatThrownBy(() -> service.createToken("user-1", "CLI", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.token.name.duplicate"); + } +} diff --git a/server/skillhub-auth/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/server/skillhub-auth/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..fdbd0b15 --- /dev/null +++ b/server/skillhub-auth/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml index 425105bc..f3a4b73b 100644 --- a/server/skillhub-domain/pom.xml +++ b/server/skillhub-domain/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 skillhub-domain diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java new file mode 100644 index 00000000..da4eef04 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java @@ -0,0 +1,81 @@ +package com.iflytek.skillhub.domain.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import java.time.Instant; + +@Entity +@Table(name = "audit_log") +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "actor_user_id", length = 128) + private String actorUserId; + + @Column(nullable = false, length = 64) + private String action; + + @Column(name = "target_type", length = 64) + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "client_ip", length = 64) + private String clientIp; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "detail_json") + @JdbcTypeCode(SqlTypes.JSON) + private String detailJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected AuditLog() {} + + public AuditLog(String actorUserId, + String action, + String targetType, + Long targetId, + String requestId, + String clientIp, + String userAgent, + String detailJson, + Instant createdAt) { + this.actorUserId = actorUserId; + this.action = action; + this.targetType = targetType; + this.targetId = targetId; + this.requestId = requestId; + this.clientIp = clientIp; + this.userAgent = userAgent; + this.detailJson = detailJson; + this.createdAt = createdAt; + } + + public Long getId() { return id; } + public String getActorUserId() { return actorUserId; } + public String getAction() { return action; } + public String getTargetType() { return targetType; } + public Long getTargetId() { return targetId; } + public String getRequestId() { return requestId; } + public String getClientIp() { return clientIp; } + public String getUserAgent() { return userAgent; } + public String getDetailJson() { return detailJson; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java new file mode 100644 index 00000000..b2f74ef0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class AuditLogQueryService { + + private final AuditLogRepository auditLogRepository; + + public AuditLogQueryService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + public Page list(int page, int size, String actorUserId, String action) { + return auditLogRepository.search(actorUserId, action, PageRequest.of(page, size)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java new file mode 100644 index 00000000..bd967a41 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AuditLogRepository { + AuditLog save(AuditLog auditLog); + Page search(String actorUserId, String action, Pageable pageable); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java new file mode 100644 index 00000000..e477abe7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; + +@Service +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + private final Clock clock; + + public AuditLogService(AuditLogRepository auditLogRepository, Clock clock) { + this.auditLogRepository = auditLogRepository; + this.clock = clock; + } + + @Transactional + public AuditLog record(String actorUserId, + String action, + String targetType, + Long targetId, + String requestId, + String clientIp, + String userAgent, + String detailJson) { + Instant createdAt = Instant.now(clock); + return auditLogRepository.save(new AuditLog( + actorUserId, + action, + targetType, + targetId, + requestId, + clientIp, + userAgent, + detailJson, + createdAt + )); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java new file mode 100644 index 00000000..519c84a9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.domain.governance; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GovernanceNotificationService { + + private final UserNotificationRepository userNotificationRepository; + private final Clock clock; + + public GovernanceNotificationService(UserNotificationRepository userNotificationRepository, Clock clock) { + this.userNotificationRepository = userNotificationRepository; + this.clock = clock; + } + + @Transactional + public UserNotification notifyUser(String userId, + String category, + String entityType, + Long entityId, + String title, + String bodyJson) { + Instant createdAt = Instant.now(clock); + return userNotificationRepository.save(new UserNotification( + userId, + category, + entityType, + entityId, + title, + bodyJson, + createdAt + )); + } + + @Transactional(readOnly = true) + public List listNotifications(String userId) { + return userNotificationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + @Transactional + public UserNotification markRead(Long notificationId, String userId) { + UserNotification notification = userNotificationRepository.findById(notificationId) + .orElseThrow(() -> new DomainNotFoundException("error.notification.notFound", notificationId)); + if (!notification.getUserId().equals(userId)) { + throw new DomainForbiddenException("error.notification.noPermission"); + } + notification.markRead(Instant.now(clock)); + return userNotificationRepository.save(notification); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java new file mode 100644 index 00000000..6f95afbd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java @@ -0,0 +1,112 @@ +package com.iflytek.skillhub.domain.governance; + +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.Table; +import java.time.Instant; + +@Entity +@Table(name = "user_notification") +public class UserNotification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 64) + private String category; + + @Column(name = "entity_type", nullable = false, length = 64) + private String entityType; + + @Column(name = "entity_id", nullable = false) + private Long entityId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "body_json", columnDefinition = "TEXT") + private String bodyJson; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserNotificationStatus status = UserNotificationStatus.UNREAD; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "read_at") + private Instant readAt; + + protected UserNotification() { + } + + public UserNotification(String userId, + String category, + String entityType, + Long entityId, + String title, + String bodyJson, + Instant createdAt) { + this.userId = userId; + this.category = category; + this.entityType = entityType; + this.entityId = entityId; + this.title = title; + this.bodyJson = bodyJson; + this.createdAt = createdAt; + } + + public void markRead(Instant readAt) { + this.status = UserNotificationStatus.READ; + this.readAt = readAt; + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getCategory() { + return category; + } + + public String getEntityType() { + return entityType; + } + + public Long getEntityId() { + return entityId; + } + + public String getTitle() { + return title; + } + + public String getBodyJson() { + return bodyJson; + } + + public UserNotificationStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getReadAt() { + return readAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java new file mode 100644 index 00000000..85563adf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.governance; + +import java.util.List; +import java.util.Optional; + +public interface UserNotificationRepository { + UserNotification save(UserNotification notification); + Optional findById(Long id); + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java new file mode 100644 index 00000000..526daa9f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.domain.governance; + +public enum UserNotificationStatus { + UNREAD, + READ +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java new file mode 100644 index 00000000..947d414f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GlobalNamespaceMembershipService { + + private static final String GLOBAL_NAMESPACE_SLUG = "global"; + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + + public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + } + + @Transactional + public void ensureMember(String userId) { + Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) + .orElseGet(() -> namespaceMemberRepository.save( + new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) + )); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java index 55073de8..1f4c2ac8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.namespace; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "namespace") @@ -34,10 +35,10 @@ public class Namespace { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected Namespace() {} @@ -49,13 +50,13 @@ public Namespace(String slug, String displayName, String createdBy) { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -65,11 +66,12 @@ void preUpdate() { public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public NamespaceStatus getStatus() { return status; } + public void setStatus(NamespaceStatus status) { this.status = status; } public NamespaceType getType() { return type; } public void setType(NamespaceType type) { this.type = type; } public String getAvatarUrl() { return avatarUrl; } public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } public String getCreatedBy() { return createdBy; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java new file mode 100644 index 00000000..d798a7d5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Component; + +@Component +public class NamespaceAccessPolicy { + + public boolean isImmutable(Namespace namespace) { + return namespace.getType() == NamespaceType.GLOBAL; + } + + public boolean canMutateSettings(Namespace namespace) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE; + } + + public boolean canManageMembers(Namespace namespace) { + return canMutateSettings(namespace); + } + + public boolean canTransferOwnership(Namespace namespace) { + return canMutateSettings(namespace); + } + + public boolean canFreeze(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE + && (role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN); + } + + public boolean canUnfreeze(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.FROZEN + && (role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN); + } + + public boolean canArchive(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() != NamespaceStatus.ARCHIVED + && role == NamespaceRole.OWNER; + } + + public boolean canRestore(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ARCHIVED + && role == NamespaceRole.OWNER; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java new file mode 100644 index 00000000..dda4574b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NamespaceGovernanceService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final AuditLogService auditLogService; + + public NamespaceGovernanceService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + NamespaceAccessPolicy namespaceAccessPolicy, + AuditLogService auditLogService) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.auditLogService = auditLogService; + } + + @Transactional + public Namespace freezeNamespace(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canFreeze(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.FROZEN); + Namespace updated = namespaceRepository.save(namespace); + record("FREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + + @Transactional + public Namespace unfreezeNamespace(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canUnfreeze(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("UNFREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + + @Transactional + public Namespace archiveNamespace(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canArchive(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ARCHIVED); + Namespace updated = namespaceRepository.save(namespace); + record("ARCHIVE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + + @Transactional + public Namespace restoreNamespace(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canRestore(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("RESTORE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + + private Namespace loadNamespaceBySlug(String slug) { + Namespace namespace = namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", slug); + } + return namespace; + } + + private NamespaceRole requireRole(Long namespaceId, String userId) { + return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + } + + private void record(String action, + String actorUserId, + Long namespaceId, + String requestId, + String clientIp, + String userAgent, + String reason) { + auditLogService.record( + actorUserId, + action, + "NAMESPACE", + namespaceId, + requestId, + clientIp, + userAgent, + reason == null || reason.isBlank() ? null : "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}" + ); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java index 1edc7323..541b85a2 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.namespace; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "namespace_member", @@ -22,10 +23,10 @@ public class NamespaceMember { private NamespaceRole role; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected NamespaceMember() {} @@ -37,13 +38,13 @@ public NamespaceMember(Long namespaceId, String userId, NamespaceRole role) { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -53,6 +54,6 @@ void preUpdate() { public void setUserId(String userId) { this.userId = userId; } public NamespaceRole getRole() { return role; } public void setRole(NamespaceRole role) { this.role = role; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index 11b55a83..82c1c068 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -13,15 +13,19 @@ public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceService namespaceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, - NamespaceService namespaceService) { + NamespaceService namespaceService, + NamespaceAccessPolicy namespaceAccessPolicy) { this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceService = namespaceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; } @Transactional public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); if (role == NamespaceRole.OWNER) { @@ -38,6 +42,7 @@ public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) @@ -52,6 +57,7 @@ public void removeMember(Long namespaceId, String userId, String operatorUserId) @Transactional public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); if (newRole == NamespaceRole.OWNER) { @@ -67,6 +73,11 @@ public NamespaceMember updateMemberRole(Long namespaceId, String userId, Namespa @Transactional public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { + Namespace namespace = namespaceService.getNamespace(namespaceId); + if (!namespaceAccessPolicy.canTransferOwnership(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); @@ -92,4 +103,14 @@ public Optional getMemberRole(Long namespaceId, String userId) { public Page listMembers(Long namespaceId, Pageable pageable) { return namespaceMemberRepository.findByNamespaceId(namespaceId, pageable); } + + private void assertMemberMutationAllowed(Long namespaceId) { + Namespace namespace = namespaceService.getNamespace(namespaceId); + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 6062b28b..fef5ea3d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -5,16 +5,21 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; + @Service public class NamespaceService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + NamespaceAccessPolicy namespaceAccessPolicy) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceAccessPolicy = namespaceAccessPolicy; } @Transactional @@ -41,7 +46,9 @@ public Namespace updateNamespace(Long namespaceId, String displayName, String de String operatorUserId) { Namespace namespace = namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + assertNotImmutable(namespace); assertAdminOrOwner(namespaceId, operatorUserId); + assertWritable(namespace); if (displayName != null) { namespace.setDisplayName(displayName); @@ -61,7 +68,23 @@ public Namespace getNamespaceBySlug(String slug) { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } - void assertAdminOrOwner(Long namespaceId, String userId) { + public Namespace getNamespaceBySlugForRead(String slug, String userId, Map userNsRoles) { + Namespace namespace = getNamespaceBySlug(slug); + if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { + return namespace; + } + if (userId != null && userNsRoles != null && userNsRoles.containsKey(namespace.getId())) { + return namespace; + } + throw new DomainBadRequestException("error.namespace.slug.notFound", slug); + } + + public Namespace getNamespace(Long namespaceId) { + return namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + } + + public void assertAdminOrOwner(Long namespaceId, String userId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole) .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); @@ -69,4 +92,26 @@ void assertAdminOrOwner(Long namespaceId, String userId) { throw new DomainForbiddenException("error.namespace.admin.required"); } } + + public void assertMember(Long namespaceId, String userId) { + namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + } + + void assertMutable(Namespace namespace) { + assertNotImmutable(namespace); + assertWritable(namespace); + } + + void assertNotImmutable(Namespace namespace) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + } + + private void assertWritable(Namespace namespace) { + if (!namespaceAccessPolicy.canMutateSettings(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java new file mode 100644 index 00000000..4e369abf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java @@ -0,0 +1,129 @@ +package com.iflytek.skillhub.domain.report; + +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; + +@Entity +@Table(name = "skill_report") +public class SkillReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "reporter_id", nullable = false, length = 128) + private String reporterId; + + @Column(nullable = false, length = 200) + private String reason; + + @Column(columnDefinition = "TEXT") + private String details; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private SkillReportStatus status = SkillReportStatus.PENDING; + + @Column(name = "handled_by", length = 128) + private String handledBy; + + @Column(name = "handle_comment", columnDefinition = "TEXT") + private String handleComment; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "handled_at") + private Instant handledAt; + + protected SkillReport() { + } + + public SkillReport(Long skillId, Long namespaceId, String reporterId, String reason, String details) { + this.skillId = skillId; + this.namespaceId = namespaceId; + this.reporterId = reporterId; + this.reason = reason; + this.details = details; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public Long getId() { + return id; + } + + public Long getSkillId() { + return skillId; + } + + public Long getNamespaceId() { + return namespaceId; + } + + public String getReporterId() { + return reporterId; + } + + public String getReason() { + return reason; + } + + public String getDetails() { + return details; + } + + public SkillReportStatus getStatus() { + return status; + } + + public void setStatus(SkillReportStatus status) { + this.status = status; + } + + public String getHandledBy() { + return handledBy; + } + + public void setHandledBy(String handledBy) { + this.handledBy = handledBy; + } + + public String getHandleComment() { + return handleComment; + } + + public void setHandleComment(String handleComment) { + this.handleComment = handleComment; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getHandledAt() { + return handledAt; + } + + public void setHandledAt(Instant handledAt) { + this.handledAt = handledAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java new file mode 100644 index 00000000..c3b376ea --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.report; + +public enum SkillReportDisposition { + RESOLVE_ONLY, + RESOLVE_AND_HIDE, + RESOLVE_AND_ARCHIVE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java new file mode 100644 index 00000000..291e3503 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.report; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SkillReportRepository { + SkillReport save(SkillReport report); + Optional findById(Long id); + boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); + Page findByStatus(SkillReportStatus status, Pageable pageable); + List findBySkillIdIn(Collection skillIds); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java new file mode 100644 index 00000000..cb96f00d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -0,0 +1,158 @@ +package com.iflytek.skillhub.domain.report; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +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.SkillStatus; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import java.time.Clock; +import java.time.Instant; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillReportService { + + private final SkillRepository skillRepository; + private final SkillReportRepository skillReportRepository; + private final AuditLogService auditLogService; + private final SkillGovernanceService skillGovernanceService; + private final GovernanceNotificationService governanceNotificationService; + private final Clock clock; + + public SkillReportService(SkillRepository skillRepository, + SkillReportRepository skillReportRepository, + AuditLogService auditLogService, + SkillGovernanceService skillGovernanceService, + GovernanceNotificationService governanceNotificationService, + Clock clock) { + this.skillRepository = skillRepository; + this.skillReportRepository = skillReportRepository; + this.auditLogService = auditLogService; + this.skillGovernanceService = skillGovernanceService; + this.governanceNotificationService = governanceNotificationService; + this.clock = clock; + } + + @Transactional + public SkillReport submitReport(Long skillId, + String reporterId, + String reason, + String details, + String clientIp, + String userAgent) { + if (reason == null || reason.isBlank()) { + throw new DomainBadRequestException("error.skill.report.reason.required"); + } + + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + if (skill.getStatus() != SkillStatus.ACTIVE || skill.isHidden()) { + throw new DomainBadRequestException("error.skill.report.unavailable", skill.getSlug()); + } + if (skill.getOwnerId().equals(reporterId)) { + throw new DomainBadRequestException("error.skill.report.self"); + } + if (skillReportRepository.existsBySkillIdAndReporterIdAndStatus(skillId, reporterId, SkillReportStatus.PENDING)) { + throw new DomainBadRequestException("error.skill.report.duplicate"); + } + + SkillReport saved = skillReportRepository.save(new SkillReport( + skillId, + skill.getNamespaceId(), + reporterId, + reason.trim(), + normalize(details) + )); + auditLogService.record(reporterId, "REPORT_SKILL", "SKILL", skillId, null, clientIp, userAgent, + "{\"reportId\":" + saved.getId() + "}"); + return saved; + } + + @Transactional + public SkillReport resolveReport(Long reportId, + String actorUserId, + String comment, + String clientIp, + String userAgent) { + return resolveReport(reportId, actorUserId, SkillReportDisposition.RESOLVE_ONLY, comment, clientIp, userAgent); + } + + @Transactional + public SkillReport resolveReport(Long reportId, + String actorUserId, + SkillReportDisposition disposition, + String comment, + String clientIp, + String userAgent) { + SkillReport report = requirePendingReport(reportId); + if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) { + skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + } else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) { + skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + } + report.setStatus(SkillReportStatus.RESOLVED); + report.setHandledBy(actorUserId); + report.setHandleComment(normalize(comment)); + report.setHandledAt(currentTime()); + SkillReport saved = skillReportRepository.save(report); + auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + governanceNotificationService.notifyUser( + report.getReporterId(), + "REPORT", + "SKILL_REPORT", + reportId, + "Report handled", + "{\"status\":\"RESOLVED\"}" + ); + return saved; + } + + @Transactional + public SkillReport dismissReport(Long reportId, + String actorUserId, + String comment, + String clientIp, + String userAgent) { + SkillReport report = requirePendingReport(reportId); + report.setStatus(SkillReportStatus.DISMISSED); + report.setHandledBy(actorUserId); + report.setHandleComment(normalize(comment)); + report.setHandledAt(currentTime()); + SkillReport saved = skillReportRepository.save(report); + auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + governanceNotificationService.notifyUser( + report.getReporterId(), + "REPORT", + "SKILL_REPORT", + reportId, + "Report dismissed", + "{\"status\":\"DISMISSED\"}" + ); + return saved; + } + + private SkillReport requirePendingReport(Long reportId) { + SkillReport report = skillReportRepository.findById(reportId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.report.notFound", reportId)); + if (report.getStatus() != SkillReportStatus.PENDING) { + throw new DomainBadRequestException("error.skill.report.alreadyHandled"); + } + return report; + } + + private String normalize(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private Instant currentTime() { + return Instant.now(clock); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java new file mode 100644 index 00000000..527d8ac5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.report; + +public enum SkillReportStatus { + PENDING, + RESOLVED, + DISMISSED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 51ff4860..4275959b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -8,6 +8,7 @@ public interface PromotionRequestRepository { PromotionRequest save(PromotionRequest request); Optional findById(Long id); Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); + Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Long targetSkillId, Integer expectedVersion); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 919d847f..7ee3c953 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -1,9 +1,11 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; 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.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -13,7 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import java.util.ConcurrentModificationException; import java.util.List; import java.util.Map; @@ -29,6 +32,8 @@ public class PromotionService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final GovernanceNotificationService governanceNotificationService; + private final Clock clock; public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, @@ -36,7 +41,9 @@ public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + GovernanceNotificationService governanceNotificationService, + Clock clock) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -44,6 +51,55 @@ public PromotionService(PromotionRequestRepository promotionRequestRepository, this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.governanceNotificationService = governanceNotificationService; + this.clock = clock; + } + + @Transactional + public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, + Long targetNamespaceId, String userId, + Map userNamespaceRoles, + Set platformRoles) { + Skill sourceSkill = skillRepository.findById(sourceSkillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); + + SkillVersion sourceVersion = skillVersionRepository.findById(sourceVersionId) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", sourceVersionId)); + + if (!sourceVersion.getSkillId().equals(sourceSkillId)) { + throw new DomainBadRequestException("promotion.version_skill_mismatch", sourceVersionId, sourceSkillId); + } + + if (sourceVersion.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); + } + + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + assertNamespaceActive(sourceNamespace); + + if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("promotion.submit.no_permission"); + } + + Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", targetNamespaceId)); + + if (targetNamespace.getType() != NamespaceType.GLOBAL) { + throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId); + } + + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.PENDING) + .ifPresent(existing -> { + throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); + }); + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.APPROVED) + .ifPresent(existing -> { + throw new DomainBadRequestException("promotion.already_promoted", sourceSkillId); + }); + + PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); + return promotionRequestRepository.save(request); } @Transactional @@ -64,6 +120,10 @@ public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + assertNamespaceActive(sourceNamespace); + if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles)) { throw new DomainForbiddenException("promotion.submit.no_permission"); } @@ -75,10 +135,14 @@ public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId); } - promotionRequestRepository.findBySourceVersionIdAndStatus(sourceVersionId, ReviewTaskStatus.PENDING) + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.PENDING) .ifPresent(existing -> { throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); }); + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.APPROVED) + .ifPresent(existing -> { + throw new DomainBadRequestException("promotion.already_promoted", sourceSkillId); + }); PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); return promotionRequestRepository.save(request); @@ -104,14 +168,17 @@ public PromotionRequest approvePromotion(Long promotionId, String reviewerId, throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); + PromotionRequest approvedRequest = promotionRequestRepository.findById(promotionId) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); + + Skill sourceSkill = skillRepository.findById(approvedRequest.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", approvedRequest.getSourceSkillId())); - SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId())); + SkillVersion sourceVersion = skillVersionRepository.findById(approvedRequest.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", approvedRequest.getSourceVersionId())); // Create new skill in global namespace - Skill newSkill = new Skill(request.getTargetNamespaceId(), sourceSkill.getSlug(), + Skill newSkill = new Skill(approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug(), sourceSkill.getOwnerId(), SkillVisibility.PUBLIC); newSkill.setDisplayName(sourceSkill.getDisplayName()); newSkill.setSummary(sourceSkill.getSummary()); @@ -124,7 +191,7 @@ public PromotionRequest approvePromotion(Long promotionId, String reviewerId, SkillVersion newVersion = new SkillVersion(newSkill.getId(), sourceVersion.getVersion(), sourceVersion.getCreatedBy()); newVersion.setStatus(SkillVersionStatus.PUBLISHED); - newVersion.setPublishedAt(LocalDateTime.now()); + newVersion.setPublishedAt(currentTime()); newVersion.setChangelog(sourceVersion.getChangelog()); newVersion.setParsedMetadataJson(sourceVersion.getParsedMetadataJson()); newVersion.setManifestJson(sourceVersion.getManifestJson()); @@ -137,7 +204,7 @@ public PromotionRequest approvePromotion(Long promotionId, String reviewerId, skillRepository.save(newSkill); // Copy file records (reuse storageKey) - List sourceFiles = skillFileRepository.findByVersionId(request.getSourceVersionId()); + List sourceFiles = skillFileRepository.findByVersionId(approvedRequest.getSourceVersionId()); Long newVersionId = newVersion.getId(); List copiedFiles = sourceFiles.stream() .map(f -> new SkillFile(newVersionId, f.getFilePath(), f.getFileSize(), @@ -146,13 +213,21 @@ public PromotionRequest approvePromotion(Long promotionId, String reviewerId, skillFileRepository.saveAll(copiedFiles); // Update promotion request with target skill id - request.setTargetSkillId(newSkill.getId()); - promotionRequestRepository.save(request); + approvedRequest.setTargetSkillId(newSkill.getId()); + PromotionRequest savedRequest = promotionRequestRepository.save(approvedRequest); eventPublisher.publishEvent(new SkillPublishedEvent( newSkill.getId(), newVersion.getId(), reviewerId)); - - return request; + governanceNotificationService.notifyUser( + approvedRequest.getSubmittedBy(), + "PROMOTION", + "PROMOTION_REQUEST", + promotionId, + "Promotion approved", + "{\"status\":\"APPROVED\"}" + ); + + return savedRequest; } @Transactional @@ -174,7 +249,32 @@ public PromotionRequest rejectPromotion(Long promotionId, String reviewerId, if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + governanceNotificationService.notifyUser( + request.getSubmittedBy(), + "PROMOTION", + "PROMOTION_REQUEST", + promotionId, + "Promotion rejected", + "{\"status\":\"REJECTED\"}" + ); return promotionRequestRepository.findById(promotionId).orElse(request); } + + public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { + return permissionChecker.canViewPromotion(request, userId, platformRoles); + } + + private void assertNamespaceActive(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } + } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 4983a4a7..21991214 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -19,45 +19,63 @@ public boolean canSubmitReview(Long namespaceId, || role == NamespaceRole.MEMBER; } - public boolean canManageNamespaceReviews(Long namespaceId, - NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { - if (namespaceType == NamespaceType.GLOBAL) { - return hasPlatformReviewRole(platformRoles); - } - - NamespaceRole role = userNamespaceRoles.get(namespaceId); - return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; - } - - /** - * Check if a user can review a ReviewTask. - * - * @param task the review task - * @param userId the reviewer's user ID - * @param namespaceType the type of the namespace - * @param userNamespaceRoles user's roles keyed by namespace ID - * @param platformRoles user's platform-level roles - * @return true if the user is allowed to review - */ public boolean canReview(ReviewTask task, String userId, NamespaceType namespaceType, Map userNamespaceRoles, Set platformRoles) { - // Cannot review own submission if (task.getSubmittedBy().equals(userId)) { + return platformRoles.contains("SUPER_ADMIN"); + } + return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canSubmitForReview(Skill skill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + if (skill.getOwnerId().equals(userId)) { + return true; + } + if (hasPlatformReviewRole(platformRoles)) { + return true; + } + + NamespaceRole role = userNamespaceRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + } + + public boolean canViewReview(ReviewTask task, + String userId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (task.getSubmittedBy().equals(userId)) { + return true; + } + return canReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canReviewNamespace(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (hasPlatformReviewRole(platformRoles)) { + return true; + } + if (namespaceType == NamespaceType.GLOBAL) { return false; } - // Global namespace: only SKILL_ADMIN or SUPER_ADMIN - return canManageNamespaceReviews( - task.getNamespaceId(), - namespaceType, - userNamespaceRoles, - platformRoles - ); + NamespaceRole role = userNamespaceRoles.get(namespaceId); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } + + public boolean canManageNamespaceReviews(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return canReviewNamespace(namespaceId, namespaceType, userNamespaceRoles, platformRoles); } public boolean canReadReview(ReviewTask task, @@ -65,30 +83,22 @@ public boolean canReadReview(ReviewTask task, NamespaceType namespaceType, Map userNamespaceRoles, Set platformRoles) { - return task.getSubmittedBy().equals(userId) - || canManageNamespaceReviews( - task.getNamespaceId(), - namespaceType, - userNamespaceRoles, - platformRoles - ); + return canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); } public boolean canSubmitPromotion(Skill sourceSkill, String userId, - Map userNamespaceRoles) { - if (sourceSkill.getOwnerId().equals(userId)) { - return true; - } + Map userNamespaceRoles, + Set platformRoles) { + return canSubmitForReview(sourceSkill, userId, userNamespaceRoles, platformRoles); + } - NamespaceRole role = userNamespaceRoles.get(sourceSkill.getNamespaceId()); - return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + public boolean canSubmitPromotion(Skill sourceSkill, + String userId, + Map userNamespaceRoles) { + return canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, Set.of()); } - /** - * Check if a user can review a PromotionRequest. - * Only SKILL_ADMIN or SUPER_ADMIN, and not own. - */ public boolean canReviewPromotion( PromotionRequest request, String userId, @@ -99,6 +109,15 @@ public boolean canReviewPromotion( return hasPlatformReviewRole(platformRoles); } + public boolean canViewPromotion(PromotionRequest request, + String userId, + Set platformRoles) { + if (request.getSubmittedBy().equals(userId)) { + return true; + } + return canReviewPromotion(request, userId, platformRoles); + } + public boolean canListPendingPromotions(Set platformRoles) { return hasPlatformReviewRole(platformRoles); } @@ -106,8 +125,7 @@ public boolean canListPendingPromotions(Set platformRoles) { public boolean canReadPromotion(PromotionRequest request, String userId, Set platformRoles) { - return request.getSubmittedBy().equals(userId) - || canListPendingPromotions(platformRoles); + return canViewPromotion(request, userId, platformRoles); } private boolean hasPlatformReviewRole(Set platformRoles) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 205a2054..4802ac11 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -4,7 +4,9 @@ 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.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -14,13 +16,16 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import java.util.ConcurrentModificationException; +import java.util.List; import java.util.Map; import java.util.Set; @@ -34,6 +39,9 @@ public class ReviewService { private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; + private final SkillGovernanceService skillGovernanceService; + private final GovernanceNotificationService governanceNotificationService; + private final Clock clock; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, @@ -41,7 +49,10 @@ public ReviewService(ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + SkillGovernanceService skillGovernanceService, + GovernanceNotificationService governanceNotificationService, + Clock clock) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; @@ -49,6 +60,42 @@ public ReviewService(ReviewTaskRepository reviewTaskRepository, this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; + this.skillGovernanceService = skillGovernanceService; + this.governanceNotificationService = governanceNotificationService; + this.clock = clock; + } + + @Transactional + public ReviewTask submitReview(Long skillVersionId, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); + + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + assertNamespaceActive(namespace); + + if (!permissionChecker.canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("review.submit.no_permission"); + } + + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); + } + + skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); + skillVersionRepository.save(skillVersion); + + ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + try { + return reviewTaskRepository.save(task); + } catch (DataIntegrityViolationException e) { + throw new DomainBadRequestException("review.submit.duplicate", skillVersionId); + } } @Transactional @@ -60,6 +107,9 @@ public ReviewTask submitReview(Long skillVersionId, Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + assertNamespaceActive(namespace); if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); @@ -93,6 +143,7 @@ public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String com Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + assertNamespaceActive(namespace); if (!permissionChecker.canReview(task, reviewerId, namespace.getType(), userNamespaceRoles, platformRoles)) { @@ -107,12 +158,30 @@ public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String com SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); - skillVersion.setStatus(SkillVersionStatus.PUBLISHED); - skillVersion.setPublishedAt(LocalDateTime.now()); - skillVersionRepository.save(skillVersion); + if (skillVersion.getStatus() != SkillVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("review.not_pending", reviewTaskId); + } Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + + // Check no other owner has a published skill with the same slug + List sameSlugSkills = skillRepository.findByNamespaceIdAndSlug(skill.getNamespaceId(), skill.getSlug()); + for (Skill other : sameSlugSkills) { + if (!other.getId().equals(skill.getId())) { + boolean otherHasPublished = !skillVersionRepository + .findBySkillIdAndStatus(other.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + if (otherHasPublished) { + throw new DomainBadRequestException("error.skill.approve.nameConflict", skill.getSlug()); + } + } + } + + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + skillVersion.setPublishedAt(currentTime()); + skillVersionRepository.save(skillVersion); + skill.setLatestVersionId(skillVersion.getId()); applyPublishedMetadata(skill, skillVersion); skill.setUpdatedBy(reviewerId); @@ -120,6 +189,14 @@ public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String com eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); + governanceNotificationService.notifyUser( + task.getSubmittedBy(), + "REVIEW", + "REVIEW_TASK", + reviewTaskId, + "Review approved", + "{\"status\":\"APPROVED\"}" + ); // Reload to return updated state return reviewTaskRepository.findById(reviewTaskId).orElse(task); @@ -138,6 +215,7 @@ public ReviewTask rejectReview(Long reviewTaskId, String reviewerId, String comm Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + assertNamespaceActive(namespace); if (!permissionChecker.canReview(task, reviewerId, namespace.getType(), userNamespaceRoles, platformRoles)) { @@ -154,12 +232,20 @@ public ReviewTask rejectReview(Long reviewTaskId, String reviewerId, String comm .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); + governanceNotificationService.notifyUser( + task.getSubmittedBy(), + "REVIEW", + "REVIEW_TASK", + reviewTaskId, + "Review rejected", + "{\"status\":\"REJECTED\"}" + ); return reviewTaskRepository.findById(reviewTaskId).orElse(task); } @Transactional - public void withdrawReview(Long skillVersionId, String userId) { + public SkillVersion withdrawReview(Long skillVersionId, String userId) { ReviewTask task = reviewTaskRepository.findBySkillVersionIdAndStatus( skillVersionId, ReviewTaskStatus.PENDING) .orElseThrow(() -> new DomainNotFoundException("review_task.not_found_for_version", skillVersionId)); @@ -172,8 +258,28 @@ public void withdrawReview(Long skillVersionId, String userId) { SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); - skillVersion.setStatus(SkillVersionStatus.DRAFT); - skillVersionRepository.save(skillVersion); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + assertNamespaceActive(namespace); + return skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId); + } + + public boolean canReviewNamespace(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canViewReview(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); } private void applyPublishedMetadata(Skill skill, SkillVersion skillVersion) { @@ -190,4 +296,17 @@ private void applyPublishedMetadata(Skill skill, SkillVersion skillVersion) { throw new IllegalStateException("Failed to deserialize skill metadata", e); } } + + private void assertNamespaceActive(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } + } + + private Instant currentTime() { + return Instant.now(clock); + } } 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 9dfd545c..9d3a3c49 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 @@ -8,6 +8,7 @@ public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 32598bf3..def07ce8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -2,7 +2,8 @@ import jakarta.persistence.*; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill") @@ -44,6 +45,15 @@ public class Skill { @Column(name = "download_count", nullable = false) private Long downloadCount = 0L; + @Column(nullable = false) + private boolean hidden = false; + + @Column(name = "hidden_at") + private Instant hiddenAt; + + @Column(name = "hidden_by", length = 128) + private String hiddenBy; + @Column(name = "star_count", nullable = false) private Integer starCount = 0; @@ -57,13 +67,13 @@ public class Skill { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_by") private String updatedBy; @Column(name = "updated_at") - private LocalDateTime updatedAt; + private Instant updatedAt; protected Skill() { } @@ -78,13 +88,13 @@ public Skill(Long namespaceId, String slug, String ownerId, SkillVisibility visi @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); + updatedAt = createdAt; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(Clock.systemUTC()); } // Getters @@ -132,6 +142,18 @@ public Long getDownloadCount() { return downloadCount; } + public boolean isHidden() { + return hidden; + } + + public Instant getHiddenAt() { + return hiddenAt; + } + + public String getHiddenBy() { + return hiddenBy; + } + public Integer getStarCount() { return starCount; } @@ -148,7 +170,7 @@ public String getCreatedBy() { return createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } @@ -156,7 +178,7 @@ public String getUpdatedBy() { return updatedBy; } - public LocalDateTime getUpdatedAt() { + public Instant getUpdatedAt() { return updatedAt; } @@ -192,4 +214,16 @@ public void setCreatedBy(String createdBy) { public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } + + public void setHiddenAt(Instant hiddenAt) { + this.hiddenAt = hiddenAt; + } + + public void setHiddenBy(String hiddenBy) { + this.hiddenBy = hiddenBy; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java index 3caa640e..5dd936ef 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_file") @@ -30,7 +31,7 @@ public class SkillFile { private String storageKey; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected SkillFile() { } @@ -46,7 +47,7 @@ public SkillFile(Long versionId, String filePath, Long fileSize, String contentT @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); } // Getters @@ -78,7 +79,7 @@ public String getStorageKey() { return storageKey; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 93816ba1..f3080f4f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.skill; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; @@ -7,9 +10,14 @@ public interface SkillRepository { Optional findById(Long id); List findByIdIn(List ids); List findAll(); - Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByNamespaceIdAndSlug(Long namespaceId, String slug); + Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); Skill save(Skill skill); + void delete(Skill skill); List findByOwnerId(String ownerId); + Page findByOwnerId(String ownerId, Pageable pageable); void incrementDownloadCount(Long skillId); + List findBySlug(String slug); + Optional findByNamespaceSlugAndSlug(String namespaceSlug, String slug); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java index 1bf6c292..abfba529 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_tag") @@ -24,10 +25,10 @@ public class SkillTag { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at") - private LocalDateTime updatedAt; + private Instant updatedAt; protected SkillTag() { } @@ -41,13 +42,13 @@ public SkillTag(Long skillId, String tagName, Long versionId, String createdBy) @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); + updatedAt = createdAt; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(Clock.systemUTC()); } // Getters @@ -71,11 +72,11 @@ public String getCreatedBy() { return createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { + public Instant getUpdatedAt() { return updatedAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java index a05c425f..78dfe479 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -41,13 +42,28 @@ public class SkillVersion { private Long totalSize = 0L; @Column(name = "published_at") - private LocalDateTime publishedAt; + private Instant publishedAt; + + @Column(name = "bundle_ready", nullable = false) + private boolean bundleReady; + + @Column(name = "download_ready", nullable = false) + private boolean downloadReady; + + @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) private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected SkillVersion() { } @@ -61,7 +77,7 @@ public SkillVersion(Long skillId, String version, String createdBy) { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); } // Getters @@ -101,15 +117,35 @@ public Long getTotalSize() { return totalSize; } - public LocalDateTime getPublishedAt() { + public Instant getPublishedAt() { return publishedAt; } + public boolean isBundleReady() { + return bundleReady; + } + + public boolean isDownloadReady() { + return downloadReady; + } + + public Instant getYankedAt() { + return yankedAt; + } + + public String getYankedBy() { + return yankedBy; + } + + public String getYankReason() { + return yankReason; + } + public String getCreatedBy() { return createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } @@ -138,7 +174,27 @@ public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } - public void setPublishedAt(LocalDateTime publishedAt) { + public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; } + + public void setBundleReady(boolean bundleReady) { + this.bundleReady = bundleReady; + } + + public void setDownloadReady(boolean downloadReady) { + this.downloadReady = downloadReady; + } + + 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/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 7dce8b83..6a5739c7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -6,7 +6,10 @@ public interface SkillVersionRepository { Optional findById(Long id); List findByIdIn(List ids); + List findBySkillIdIn(List skillIds); + List findBySkillId(Long skillId); Optional findBySkillIdAndVersion(Long skillId, String version); List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); + void delete(SkillVersion version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java new file mode 100644 index 00000000..586f54bd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java @@ -0,0 +1,62 @@ +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; + +@Entity +@Table(name = "skill_version_stats") +public class SkillVersionStats { + + @Id + @Column(name = "skill_version_id", nullable = false) + private Long skillVersionId; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "download_count", nullable = false) + private Long downloadCount = 0L; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected SkillVersionStats() { + } + + public SkillVersionStats(Long skillVersionId, Long skillId) { + this.skillVersionId = skillVersionId; + this.skillId = skillId; + } + + @PrePersist + protected void onCreate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + public Long getSkillVersionId() { + return skillVersionId; + } + + public Long getSkillId() { + return skillId; + } + + public Long getDownloadCount() { + return downloadCount; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java new file mode 100644 index 00000000..28685aa2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.skill; + +import java.util.Optional; + +public interface SkillVersionStatsRepository { + Optional findBySkillVersionId(Long skillVersionId); + void incrementDownloadCount(Long skillVersionId, Long skillId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 57022259..9cd17cda 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,5 +4,6 @@ public enum SkillVersionStatus { DRAFT, PENDING_REVIEW, PUBLISHED, - REJECTED + REJECTED, + YANKED } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 2598a0bf..16980af1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -7,6 +7,12 @@ public class VisibilityChecker { public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { + if (skill.isHidden()) { + return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + } + if (skill.getLatestVersionId() == null) { + return isOwner(skill, currentUserId); + } return switch (skill.getVisibility()) { case PUBLIC -> true; case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index ffaae86c..a461d2d2 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.yaml.snakeyaml.Yaml; +import java.util.LinkedHashMap; import java.util.Map; public class SkillMetadataParser { @@ -35,14 +36,7 @@ public SkillMetadata parse(String content) { Map frontmatter; try { - Yaml yaml = new Yaml(); - Object parsed = yaml.load(yamlContent); - if (!(parsed instanceof Map)) { - throw new DomainBadRequestException("error.skill.metadata.yaml.notMap"); - } - @SuppressWarnings("unchecked") - Map map = (Map) parsed; - frontmatter = map; + frontmatter = parseFrontmatter(yamlContent); } catch (DomainBadRequestException e) { throw e; } catch (Exception e) { @@ -56,6 +50,62 @@ public SkillMetadata parse(String content) { return new SkillMetadata(name, description, version, body, frontmatter); } + private Map parseFrontmatter(String yamlContent) { + try { + Yaml yaml = new Yaml(); + Object parsed = yaml.load(yamlContent); + if (!(parsed instanceof Map)) { + throw new DomainBadRequestException("error.skill.metadata.yaml.notMap"); + } + @SuppressWarnings("unchecked") + Map map = (Map) parsed; + return map; + } catch (DomainBadRequestException exception) { + throw exception; + } catch (Exception exception) { + Map fallback = parseLooseFrontmatter(yamlContent); + if (!fallback.isEmpty()) { + return fallback; + } + throw exception; + } + } + + private Map parseLooseFrontmatter(String yamlContent) { + Map values = new LinkedHashMap<>(); + for (String rawLine : yamlContent.split("\\R")) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + int separatorIndex = line.indexOf(':'); + if (separatorIndex <= 0) { + continue; + } + + String key = line.substring(0, separatorIndex).trim(); + String value = line.substring(separatorIndex + 1).trim(); + if (key.isEmpty()) { + continue; + } + + values.put(key, stripWrappingQuotes(value)); + } + return values; + } + + private String stripWrappingQuotes(String value) { + if (value.length() >= 2) { + boolean wrappedInDoubleQuotes = value.startsWith("\"") && value.endsWith("\""); + boolean wrappedInSingleQuotes = value.startsWith("'") && value.endsWith("'"); + if (wrappedInDoubleQuotes || wrappedInSingleQuotes) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + private String extractRequiredField(Map frontmatter, String fieldName) { Object value = frontmatter.get(fieldName); if (value == null) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 31ee1a53..bc9678b1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,51 +4,78 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectStorageService; import com.iflytek.skillhub.storage.ObjectMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.time.Duration; +import java.util.Comparator; import java.util.Map; +import java.util.List; +import java.util.function.Supplier; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; @Service public class SkillDownloadService { + private static final Logger log = LoggerFactory.getLogger(SkillDownloadService.class); private final NamespaceRepository namespaceRepository; private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillVersionStatsRepository skillVersionStatsRepository; + private final SkillFileRepository skillFileRepository; private final SkillTagRepository skillTagRepository; private final ObjectStorageService objectStorageService; private final VisibilityChecker visibilityChecker; private final ApplicationEventPublisher eventPublisher; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillDownloadService( NamespaceRepository namespaceRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, + SkillVersionStatsRepository skillVersionStatsRepository, + SkillFileRepository skillFileRepository, SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, VisibilityChecker visibilityChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; + this.skillVersionStatsRepository = skillVersionStatsRepository; + this.skillFileRepository = skillFileRepository; this.skillTagRepository = skillTagRepository; this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; this.eventPublisher = eventPublisher; + this.skillSlugResolutionService = skillSlugResolutionService; } public record DownloadResult( - InputStream content, + Supplier contentSupplier, String filename, long contentLength, - String contentType - ) {} + String contentType, + String presignedUrl, + boolean fallbackBundle + ) { + public InputStream openContent() { + return contentSupplier.get(); + } + } public DownloadResult downloadLatest( String namespaceSlug, @@ -57,13 +84,8 @@ public DownloadResult downloadLatest( Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); - - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertCanDownload(namespace, skill, currentUserId, userNsRoles); if (skill.getLatestVersionId() == null) { throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug); @@ -83,13 +105,8 @@ public DownloadResult downloadVersion( Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); - - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertCanDownload(namespace, skill, currentUserId, userNsRoles); SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); @@ -105,13 +122,8 @@ public DownloadResult downloadByTag( Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); - - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertCanDownload(namespace, skill, currentUserId, userNsRoles); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -132,19 +144,87 @@ private DownloadResult downloadVersion(Skill skill, SkillVersion version) { String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); - if (!objectStorageService.exists(storageKey)) { + DownloadResult result; + if (objectStorageService.exists(storageKey)) { + ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); + String filename = buildFilename(skill, version); + String presignedUrl = objectStorageService.generatePresignedUrl(storageKey, Duration.ofMinutes(10), filename); + result = new DownloadResult( + () -> objectStorageService.getObject(storageKey), + filename, + metadata.size(), + metadata.contentType(), + presignedUrl, + false + ); + } else { + log.warn( + "Bundle missing for published version, falling back to per-file zip [skillId={}, versionId={}, version={}]", + skill.getId(), + version.getId(), + version.getVersion() + ); + result = buildBundleFromFiles(skill, version); + } + + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + return result; + } + + private DownloadResult buildBundleFromFiles(Skill skill, SkillVersion version) { + List files = skillFileRepository.findByVersionId(version.getId()).stream() + .filter(file -> objectStorageService.exists(file.getStorageKey())) + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + if (files.isEmpty()) { throw new DomainBadRequestException("error.skill.bundle.notFound"); } - ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); - InputStream content = objectStorageService.getObject(storageKey); + byte[] bundle = createBundle(files); + return new DownloadResult( + () -> new ByteArrayInputStream(bundle), + buildFilename(skill, version), + bundle.length, + "application/zip", + null, + true + ); + } - // Publish download event - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + private byte[] createBundle(List files) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (SkillFile file : files) { + ZipEntry zipEntry = new ZipEntry(file.getFilePath()); + zipOutputStream.putNextEntry(zipEntry); + try (InputStream inputStream = objectStorageService.getObject(file.getStorageKey())) { + inputStream.transferTo(zipOutputStream); + } + zipOutputStream.closeEntry(); + } + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build fallback bundle zip", e); + } + } - String filename = String.format("%s-%s.zip", skill.getSlug(), version.getVersion()); + private String buildFilename(Skill skill, SkillVersion version) { + String baseName = skill.getDisplayName(); + if (baseName == null || baseName.isBlank()) { + baseName = skill.getSlug(); + } + return String.format("%s-%s.zip", sanitizeFilename(baseName), version.getVersion()); + } - return new DownloadResult(content, filename, metadata.size(), metadata.contentType()); + private String sanitizeFilename(String value) { + String sanitized = value + .replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "-") + .replaceAll("\\s+", " ") + .trim(); + return sanitized.isBlank() ? "skill" : sanitized; } private Namespace findNamespace(String slug) { @@ -152,6 +232,31 @@ private Namespace findNamespace(String slug) { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private void assertCanDownload(Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + } + + private boolean isAnonymousDownloadAllowed(Namespace namespace, Skill skill) { + return namespace.getType() == NamespaceType.GLOBAL + && skill.getVisibility() == SkillVisibility.PUBLIC; + } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); + } + private void assertPublishedAccessible(Skill skill) { if (skill.getStatus() != SkillStatus.ACTIVE) { throw new DomainBadRequestException("error.skill.status.notActive"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java new file mode 100644 index 00000000..a96ccc67 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -0,0 +1,242 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +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.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +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.storage.ObjectStorageService; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillGovernanceService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final ObjectStorageService objectStorageService; + private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public SkillGovernanceService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + ObjectStorageService objectStorageService, + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher, + Clock clock) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.objectStorageService = objectStorageService; + this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + @Transactional + public Skill hideSkill(Long skillId, String actorUserId, String clientIp, String userAgent, String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + skill.setHidden(true); + skill.setHiddenAt(currentInstant()); + skill.setHiddenBy(actorUserId); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); + return saved; + } + + @Transactional + public Skill archiveSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + @Transactional + public Skill archiveSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + private Skill archiveSkillInternal(Skill skill, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + SkillStatus previousStatus = skill.getStatus(); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason)); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED)); + return saved; + } + + @Transactional + public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + skill.setHidden(false); + skill.setHiddenAt(null); + skill.setHiddenBy(null); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + return saved; + } + + @Transactional + public Skill unarchiveSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + SkillStatus previousStatus = skill.getStatus(); + skill.setStatus(SkillStatus.ACTIVE); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "UNARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE)); + return saved; + } + + @Transactional + public void deleteVersion(Skill skill, + SkillVersion version, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent) { + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); + } + + long versionCount = skillVersionRepository.findBySkillId(skill.getId()).size(); + if (versionCount <= 1) { + throw new DomainBadRequestException("error.skill.version.delete.lastVersion", version.getVersion()); + } + + List files = skillFileRepository.findByVersionId(version.getId()); + if (!files.isEmpty()) { + objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList()); + } + objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId())); + skillFileRepository.deleteByVersionId(version.getId()); + skillVersionRepository.delete(version); + auditLogService.record( + actorUserId, + "DELETE_SKILL_VERSION", + "SKILL_VERSION", + version.getId(), + null, + clientIp, + userAgent, + "{\"version\":\"" + version.getVersion().replace("\"", "\\\"") + "\"}" + ); + } + + @Transactional + public SkillVersion withdrawPendingVersion(Skill skill, + SkillVersion version, + String actorUserId) { + if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("review.withdraw.not_pending", version.getId()); + } + version.setStatus(SkillVersionStatus.DRAFT); + SkillVersion savedVersion = skillVersionRepository.save(version); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + return savedVersion; + } + + @Transactional + public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) { + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.version.notFound", versionId)); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); + } + version.setStatus(SkillVersionStatus.YANKED); + version.setYankedAt(currentInstant()); + version.setYankedBy(actorUserId); + version.setYankReason(reason); + version.setDownloadReady(false); + SkillVersion saved = skillVersionRepository.save(version); + skillRepository.findById(version.getSkillId()).ifPresent(skill -> { + if (versionId.equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } + }); + auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); + return saved; + } + + private Long findLatestPublishedVersionId(Long skillId) { + return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream() + .max(java.util.Comparator + .comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) + .thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))) + .map(SkillVersion::getId) + .orElse(null); + } + + private void assertCanManageLifecycle(Skill skill, + String actorUserId, + Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } + + private String jsonReason(String reason) { + if (reason == null || reason.isBlank()) { + return null; + } + return "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}"; + } + + private Instant currentInstant() { + return Instant.now(clock); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java new file mode 100644 index 00000000..73b2d0e7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -0,0 +1,114 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class SkillLifecycleProjectionService { + + public enum ResolutionMode { + PUBLISHED, + OWNER_PREVIEW, + NONE + } + + public record VersionProjection( + Long id, + String version, + String status + ) {} + + public record Projection( + VersionProjection headlineVersion, + VersionProjection publishedVersion, + VersionProjection ownerPreviewVersion, + ResolutionMode resolutionMode + ) {} + + private final SkillVersionRepository skillVersionRepository; + + public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) { + this.skillVersionRepository = skillVersionRepository; + } + + public Projection projectForViewer(Skill skill, String currentUserId, Map userNsRoles) { + VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); + VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles)); + VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; + ResolutionMode resolutionMode = headlineVersion == null + ? ResolutionMode.NONE + : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; + return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); + } + + public Projection projectForOwnerSummary(Skill skill) { + VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); + VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill)); + VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; + ResolutionMode resolutionMode = headlineVersion == null + ? ResolutionMode.NONE + : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; + return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); + } + + private SkillVersion resolvePublishedVersion(Skill skill) { + if (skill.getLatestVersionId() != null) { + SkillVersion latest = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + if (latest != null && latest.getStatus() == SkillVersionStatus.PUBLISHED) { + return latest; + } + } + return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED).stream() + .max(versionComparator()) + .orElse(null); + } + + private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map userNsRoles) { + if (!canManage(skill, currentUserId, userNsRoles)) { + return null; + } + return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW).stream() + .max(versionComparator()) + .orElse(null); + } + + private SkillVersion resolveNewestNonPublishedVersion(Skill skill) { + List versions = skillVersionRepository.findBySkillId(skill.getId()); + return versions.stream() + .filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED + && version.getStatus() != SkillVersionStatus.YANKED) + .max(versionComparator()) + .orElse(null); + } + + private boolean canManage(Skill skill, String currentUserId, Map userNsRoles) { + if (currentUserId == null) { + return false; + } + NamespaceRole role = userNsRoles.get(skill.getNamespaceId()); + return skill.getOwnerId().equals(currentUserId) + || role == NamespaceRole.ADMIN + || role == NamespaceRole.OWNER; + } + + private Comparator versionComparator() { + return Comparator + .comparing(SkillVersion::getPublishedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); + } + + private VersionProjection toProjection(SkillVersion version) { + if (version == null) { + return null; + } + return new VersionProjection(version.getId(), version.getVersion(), version.getStatus().name()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index ec693df4..de5238b3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -1,13 +1,18 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; @@ -16,24 +21,36 @@ import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.storage.ObjectStorageService; +import org.yaml.snakeyaml.Yaml; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.security.MessageDigest; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Comparator; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @Service public class SkillPublishService { + private static final DateTimeFormatter AUTO_VERSION_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneOffset.UTC); + public record PublishResult( Long skillId, String slug, @@ -49,9 +66,10 @@ public record PublishResult( private final SkillPackageValidator skillPackageValidator; private final SkillMetadataParser skillMetadataParser; private final PrePublishValidator prePublishValidator; - private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final ReviewTaskRepository reviewTaskRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; public SkillPublishService( NamespaceRepository namespaceRepository, @@ -63,9 +81,10 @@ public SkillPublishService( SkillPackageValidator skillPackageValidator, SkillMetadataParser skillMetadataParser, PrePublishValidator prePublishValidator, - ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, - ReviewTaskRepository reviewTaskRepository) { + ReviewTaskRepository reviewTaskRepository, + ApplicationEventPublisher eventPublisher, + Clock clock) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; @@ -75,9 +94,10 @@ public SkillPublishService( this.skillPackageValidator = skillPackageValidator; this.skillMetadataParser = skillMetadataParser; this.prePublishValidator = prePublishValidator; - this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.reviewTaskRepository = reviewTaskRepository; + this.eventPublisher = eventPublisher; + this.clock = clock; } @Transactional @@ -85,15 +105,65 @@ public PublishResult publishFromEntries( String namespaceSlug, List entries, String publisherId, - SkillVisibility visibility) { + SkillVisibility visibility, + java.util.Set platformRoles) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false); + } + + @Transactional + public PublishResult rereleasePublishedVersion( + Long skillId, + String sourceVersion, + String targetVersion, + String publisherId, + Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, publisherId, userNamespaceRoles); + + SkillVersion publishedVersion = skillVersionRepository.findBySkillIdAndVersion(skillId, sourceVersion) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", sourceVersion)); + if (publishedVersion.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.notPublished", sourceVersion); + } + if (skillVersionRepository.findBySkillIdAndVersion(skillId, targetVersion).isPresent()) { + throw new DomainBadRequestException("error.skill.version.exists", targetVersion); + } + + List entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion); + + return publishFromEntriesInternal( + resolveNamespaceSlug(skill.getNamespaceId()), + entries, + publisherId, + skill.getVisibility(), + Set.of(), + true, + true + ); + } + + private PublishResult publishFromEntriesInternal( + String namespaceSlug, + List entries, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + boolean forceAutoPublish, + boolean bypassMembershipCheck) { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); + assertNamespaceWritable(namespace); - // 2. Check publisher is member - namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId) - .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug)); + boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN"); + + // 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks + if (!isSuperAdmin && !bypassMembershipCheck) { + namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug)); + } // 3. Validate package ValidationResult packageValidation = skillPackageValidator.validate(entries); @@ -112,7 +182,8 @@ public PublishResult publishFromEntries( String skillMdContent = new String(skillMd.content()); SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); if (metadata.version() == null || metadata.version().isBlank()) { - throw new DomainBadRequestException("error.skill.metadata.requiredField.missing", "version"); + String autoVersion = AUTO_VERSION_FORMATTER.format(currentTime()); + metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter()); } String skillSlug = SlugValidator.slugify(metadata.name()); @@ -126,22 +197,62 @@ public PublishResult publishFromEntries( String.join(", ", prePublishValidation.errors())); } - // 6. Find or create Skill record - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + // 6. Find or create Skill record (with owner isolation) + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); + + // Check if any other owner's skill has published versions + for (Skill existing : existingSkills) { + if (!existing.getOwnerId().equals(publisherId)) { + boolean hasPublished = !skillVersionRepository + .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + if (hasPublished) { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } + } + } + + // Find or create skill for current user + Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId) .orElseGet(() -> { Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); newSkill.setCreatedBy(publisherId); return skillRepository.save(newSkill); }); + if (skill.getStatus() == SkillStatus.ARCHIVED) { + throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); + } + + // 6c. Auto-withdraw pending review versions + List pendingVersions = skillVersionRepository + .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW); + for (SkillVersion pending : pendingVersions) { + reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) + .ifPresent(reviewTaskRepository::delete); + pending.setStatus(SkillVersionStatus.DRAFT); + skillVersionRepository.save(pending); + } + // 7. Check version doesn't already exist - if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) { - throw new DomainBadRequestException("error.skill.version.exists", metadata.version()); + java.util.Optional existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()); + if (existingVersion.isPresent()) { + SkillVersion matchedVersion = existingVersion.get(); + if (matchedVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.exists", metadata.version()); + } + deleteReplaceableVersionArtifacts(skill, matchedVersion); } // 8. Create SkillVersion SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); + boolean autoPublish = forceAutoPublish || isSuperAdmin; + if (autoPublish) { + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(currentTime()); + } else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + } // Store metadata as JSON try { @@ -211,13 +322,124 @@ public PublishResult publishFromEntries( // 11. Update version stats version.setFileCount(skillFiles.size()); version.setTotalSize(totalSize); + version.setBundleReady(true); + version.setDownloadReady(!skillFiles.isEmpty()); skillVersionRepository.save(version); - // 12. Return published identifiers. Published-facing skill metadata is - // advanced only when review approval promotes this version to PUBLISHED. + if (!autoPublish) { + ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); + reviewTaskRepository.save(reviewTask); + } + + // 12. Update skill metadata and move the published pointer for auto-publish flows + skill.setDisplayName(metadata.name()); + skill.setSummary(metadata.description()); + if (autoPublish) { + skill.setLatestVersionId(version.getId()); + } + skill.setUpdatedBy(publisherId); + skillRepository.save(skill); + + if (autoPublish) { + eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); + } + + // 13. Return identifiers for the created version return new PublishResult(skill.getId(), skill.getSlug(), version); } + private void deleteReplaceableVersionArtifacts(Skill skill, SkillVersion version) { + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); + } + + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) + .ifPresent(reviewTaskRepository::delete); + + List files = skillFileRepository.findByVersionId(version.getId()); + if (!files.isEmpty()) { + objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList()); + } + objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId())); + skillFileRepository.deleteByVersionId(version.getId()); + skillVersionRepository.delete(version); + + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(null); + } + } + + private String resolveNamespaceSlug(Long namespaceId) { + return namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId)) + .getSlug(); + } + + private void assertNamespaceWritable(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } + } + + private void assertCanManageLifecycle(Skill skill, + String actorUserId, + Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } + + private Instant currentTime() { + return Instant.now(clock); + } + + private List rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) { + List files = skillFileRepository.findByVersionId(versionId).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + List entries = new ArrayList<>(files.size()); + for (SkillFile file : files) { + byte[] content = readAllBytes(objectStorageService.getObject(file.getStorageKey())); + if ("SKILL.md".equals(file.getFilePath())) { + content = rewriteSkillMdVersion(content, targetVersion); + } + entries.add(new PackageEntry( + file.getFilePath(), + content, + content.length, + file.getContentType() != null ? file.getContentType() : "application/octet-stream" + )); + } + return entries; + } + + private byte[] readAllBytes(InputStream inputStream) { + try (InputStream in = inputStream) { + return in.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read stored skill file", e); + } + } + + private byte[] rewriteSkillMdVersion(byte[] content, String targetVersion) { + String skillMdContent = new String(content); + SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); + Map frontmatter = new LinkedHashMap<>(metadata.frontmatter()); + frontmatter.put("version", targetVersion); + String rewritten = "---\n" + + new Yaml().dump(frontmatter).trim() + + "\n---\n" + + metadata.body(); + return rewritten.getBytes(); + } + private List> buildManifest(List entries) { return entries.stream() .map(entry -> Map.of( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 997b2982..74724362 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -3,6 +3,10 @@ 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.PromotionRequestRepository; +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.*; @@ -13,6 +17,7 @@ import org.springframework.stereotype.Service; import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -34,6 +39,9 @@ public class SkillQueryService { private final SkillTagRepository skillTagRepository; private final ObjectStorageService objectStorageService; private final VisibilityChecker visibilityChecker; + private final PromotionRequestRepository promotionRequestRepository; + private final SkillSlugResolutionService skillSlugResolutionService; + private final SkillLifecycleProjectionService skillLifecycleProjectionService; public SkillQueryService( NamespaceRepository namespaceRepository, @@ -42,7 +50,10 @@ public SkillQueryService( SkillFileRepository skillFileRepository, SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, - VisibilityChecker visibilityChecker) { + VisibilityChecker visibilityChecker, + PromotionRequestRepository promotionRequestRepository, + SkillSlugResolutionService skillSlugResolutionService, + SkillLifecycleProjectionService skillLifecycleProjectionService) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -50,6 +61,9 @@ public SkillQueryService( this.skillTagRepository = skillTagRepository; this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; + this.promotionRequestRepository = promotionRequestRepository; + this.skillSlugResolutionService = skillSlugResolutionService; + this.skillLifecycleProjectionService = skillLifecycleProjectionService; } public record SkillDetailDTO( @@ -61,8 +75,20 @@ public record SkillDetailDTO( String status, Long downloadCount, Integer starCount, - String latestVersion, - Long namespaceId + java.math.BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, + Long namespaceId, + java.time.Instant createdAt, + java.time.Instant updatedAt, + boolean canManageLifecycle, + boolean canSubmitPromotion, + boolean canInteract, + boolean canReport, + SkillLifecycleProjectionService.VersionProjection headlineVersion, + SkillLifecycleProjectionService.VersionProjection publishedVersion, + SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion, + String resolutionMode ) {} public record SkillVersionDetailDTO( @@ -72,7 +98,7 @@ public record SkillVersionDetailDTO( String changelog, Integer fileCount, Long totalSize, - java.time.LocalDateTime publishedAt, + java.time.Instant publishedAt, String parsedMetadataJson, String manifestJson ) {} @@ -95,21 +121,18 @@ public SkillDetailDTO getSkillDetail( Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } - String latestVersion = null; - if (skill.getLatestVersionId() != null) { - SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); - if (version != null) { - latestVersion = version.getVersion(); - } - } + SkillLifecycleProjectionService.Projection projection = + skillLifecycleProjectionService.projectForViewer(skill, currentUserId, userNsRoles); + SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion(); + SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion(); + SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion(); return new SkillDetailDTO( skill.getId(), @@ -120,8 +143,20 @@ public SkillDetailDTO getSkillDetail( skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), - latestVersion, - skill.getNamespaceId() + skill.getRatingAvg(), + skill.getRatingCount(), + skill.isHidden(), + skill.getNamespaceId(), + skill.getCreatedAt(), + skill.getUpdatedAt(), + canManageRestrictedSkill(skill, currentUserId, userNsRoles), + canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles), + headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()), + currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId), + headlineVersion, + publishedVersion, + ownerPreviewVersion, + projection.resolutionMode().name() ); } @@ -153,10 +188,11 @@ public SkillVersionDetailDTO getVersionDetail( String version, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); return new SkillVersionDetailDTO( skillVersion.getId(), @@ -177,13 +213,14 @@ public List listFiles( String version, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); - return skillFileRepository.findByVersionId(skillVersion.getId()); + return availableFiles(skillVersion.getId()); } public List listFilesByTag( @@ -192,10 +229,11 @@ public List listFilesByTag( String tagName, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); - return skillFileRepository.findByVersionId(skillVersion.getId()); + return availableFiles(skillVersion.getId()); } public InputStream getFileContent( @@ -205,15 +243,16 @@ public InputStream getFileContent( String filePath, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); SkillFile file = findFile(skillVersion, filePath); - return objectStorageService.getObject(file.getStorageKey()); + return readFileContent(file); } public InputStream getFileContentByTag( @@ -223,25 +262,59 @@ public InputStream getFileContentByTag( String filePath, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); - return objectStorageService.getObject(file.getStorageKey()); + return readFileContent(file); } - public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { - Skill skill = findSkill(namespaceSlug, skillSlug); - - List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( - skill.getId(), SkillVersionStatus.PUBLISHED); + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable) { + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + List visibleVersions; + if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() + .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED + || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.DRAFT + || version.getStatus() == SkillVersionStatus.REJECTED + || version.getStatus() == SkillVersionStatus.YANKED) + .sorted(Comparator + .comparingInt((SkillVersion version) -> lifecycleListPriority(version.getStatus())) + .thenComparing(SkillVersion::getPublishedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(SkillVersion::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(SkillVersion::getId, Comparator.reverseOrder())) + .toList(); + } else { + visibleVersions = skillVersionRepository.findBySkillIdAndStatus( + skill.getId(), SkillVersionStatus.PUBLISHED); + } // Manual pagination - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), publishedVersions.size()); - List pageContent = publishedVersions.subList(start, end); + int start = Math.min((int) pageable.getOffset(), visibleVersions.size()); + int end = Math.min(start + pageable.getPageSize(), visibleVersions.size()); + List pageContent = visibleVersions.subList(start, end); + + return new PageImpl<>(pageContent, pageable, visibleVersions.size()); + } - return new PageImpl<>(pageContent, pageable, publishedVersions.size()); + public boolean isDownloadAvailable(SkillVersion version) { + if (version == null) { + return false; + } + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + return false; + } + return version.isDownloadReady(); } public ResolvedVersionDTO resolveVersion( @@ -256,8 +329,9 @@ public ResolvedVersionDTO resolveVersion( throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); } - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -283,10 +357,17 @@ private Namespace findNamespace(String slug) { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } - private Skill findSkill(String namespaceSlug, String skillSlug) { + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { Namespace namespace = findNamespace(namespaceSlug); - return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } private SkillVersion findVersion(Skill skill, String version) { @@ -295,12 +376,30 @@ private SkillVersion findVersion(Skill skill, String version) { } private SkillFile findFile(SkillVersion skillVersion, String filePath) { - return skillFileRepository.findByVersionId(skillVersion.getId()).stream() + return availableFiles(skillVersion.getId()).stream() .filter(f -> f.getFilePath().equals(filePath)) .findFirst() .orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath)); } + private List availableFiles(Long versionId) { + return skillFileRepository.findByVersionId(versionId).stream() + .filter(file -> objectStorageService.exists(file.getStorageKey())) + .toList(); + } + + private String getBundleStorageKey(Long skillId, Long versionId) { + return String.format("packages/%d/%d/bundle.zip", skillId, versionId); + } + + private InputStream readFileContent(SkillFile file) { + try { + return objectStorageService.getObject(file.getStorageKey()); + } catch (UncheckedIOException e) { + throw new DomainBadRequestException("error.skill.file.notFound", file.getFilePath()); + } + } + private SkillVersion resolveVersionEntity(Skill skill, String version, String tag, String hash) { if (version != null && !version.isBlank()) { SkillVersion exactVersion = findVersion(skill, version); @@ -371,18 +470,99 @@ private String encodePathSegment(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } - private void assertPublishedAccessible(Skill skill, String currentUserId, Map userNsRoles) { - if (skill.getStatus() != SkillStatus.ACTIVE) { - throw new DomainBadRequestException("error.skill.status.notActive"); + private void assertPublishedAccessible( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); + } + if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } } + private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles) { + if (currentUserId == null) { + return false; + } + NamespaceRole role = userNsRoles.get(skill.getNamespaceId()); + return skill.getOwnerId().equals(currentUserId) + || role == NamespaceRole.ADMIN + || role == NamespaceRole.OWNER; + } + + private boolean canSubmitPromotion( + Namespace namespace, + Skill skill, + SkillLifecycleProjectionService.VersionProjection publishedVersion, + String currentUserId, + Map userNsRoles) { + if (namespace.getType() == NamespaceType.GLOBAL) { + return false; + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE) { + return false; + } + if (publishedVersion == null || !"PUBLISHED".equals(publishedVersion.status())) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) { + return false; + } + return canManageRestrictedSkill(skill, currentUserId, userNsRoles); + } + + private boolean isOwner(Skill skill, String currentUserId) { + return currentUserId != null && skill.getOwnerId().equals(currentUserId); + } + + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { + return currentUserId != null && userNsRoles.containsKey(namespaceId); + } + + private int lifecycleListPriority(SkillVersionStatus status) { + if (status == SkillVersionStatus.PUBLISHED) { + return 0; + } + if (status == SkillVersionStatus.REJECTED) { + return 1; + } + if (status == SkillVersionStatus.PENDING_REVIEW) { + return 2; + } + if (status == SkillVersionStatus.DRAFT) { + return 3; + } + if (status == SkillVersionStatus.YANKED) { + return 4; + } + return 2; + } + private void assertPublishedVersion(SkillVersion version, String versionStr) { if (version.getStatus() != SkillVersionStatus.PUBLISHED) { throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); } } + + private void assertPreviewAccessible(Skill skill, SkillVersion version, String versionStr, String currentUserId) { + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + return; + } + if (version.getStatus() == SkillVersionStatus.PENDING_REVIEW && isOwner(skill, currentUserId)) { + return; + } + throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java new file mode 100644 index 00000000..e4521da3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class SkillSlugResolutionService { + + public enum Preference { + CURRENT_USER, + PUBLISHED + } + + private final SkillRepository skillRepository; + + public SkillSlugResolutionService(SkillRepository skillRepository) { + this.skillRepository = skillRepository; + } + + public Skill resolve(Long namespaceId, String slug, String currentUserId, Preference preference) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + + Optional ownSkill = currentUserId == null + ? Optional.empty() + : skills.stream().filter(skill -> currentUserId.equals(skill.getOwnerId())).findFirst(); + Optional publishedSkill = skills.stream() + .filter(skill -> skill.getLatestVersionId() != null && !skill.isHidden()) + .findFirst(); + + if (preference == Preference.CURRENT_USER) { + return ownSkill.or(() -> publishedSkill) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); + } + + return publishedSkill.or(() -> ownSkill) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index 39ca41f9..07c3fdc0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -22,24 +22,35 @@ public class SkillTagService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; + private final VisibilityChecker visibilityChecker; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillTagService( NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - SkillTagRepository skillTagRepository) { + SkillTagRepository skillTagRepository, + VisibilityChecker visibilityChecker, + SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; + this.visibilityChecker = visibilityChecker; + this.skillSlugResolutionService = skillSlugResolutionService; } - public List listTags(String namespaceSlug, String skillSlug) { + public List listTags(String namespaceSlug, + String skillSlug, + String currentUserId, + java.util.Map userNamespaceRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } List tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId())); if (skill.getLatestVersionId() != null) { @@ -48,6 +59,10 @@ public List listTags(String namespaceSlug, String skillSlug) { return tags; } + public List listTags(String namespaceSlug, String skillSlug) { + return listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); + } + @Transactional public SkillTag createOrMoveTag( String namespaceSlug, @@ -63,8 +78,7 @@ public SkillTag createOrMoveTag( Namespace namespace = findNamespace(namespaceSlug); assertAdminOrOwner(namespace.getId(), operatorId); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId); // Find target version SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion) @@ -98,8 +112,7 @@ public void deleteTag(String namespaceSlug, String skillSlug, String tagName, St Namespace namespace = findNamespace(namespaceSlug); assertAdminOrOwner(namespace.getId(), operatorId); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -112,6 +125,14 @@ private Namespace findNamespace(String slug) { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); + } + private void assertAdminOrOwner(Long namespaceId, String operatorId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId) .map(member -> member.getRole()) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java new file mode 100644 index 00000000..86dca42c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.domain.skill.validation; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class BasicPrePublishValidator implements PrePublishValidator { + + private static final Pattern PLACEHOLDER_VALUE = Pattern.compile( + "(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*" + ); + private static final List SECRET_RULES = List.of( + new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"), + new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"), + new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"), + new SecretRule( + Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"), + 2, + "secret or token") + ); + + @Override + public ValidationResult validate(SkillPackageContext context) { + List errors = new ArrayList<>(); + + for (PackageEntry entry : context.entries()) { + if (!isTextLike(entry.path())) { + continue; + } + String content = new String(entry.content(), StandardCharsets.UTF_8); + String[] lines = content.split("\\R", -1); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + for (SecretRule rule : SECRET_RULES) { + Matcher matcher = rule.pattern().matcher(line); + if (!matcher.find()) { + continue; + } + String matchedValue = matcher.group(rule.valueGroup()); + if (isPlaceholderValue(matchedValue)) { + continue; + } + errors.add(entry.path() + + " line " + (i + 1) + + " contains a value that looks like a " + + rule.label() + + ". Replace real credentials with placeholders before publishing."); + break; + } + } + } + + return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); + } + + private boolean isTextLike(String path) { + String lowerPath = path.toLowerCase(Locale.ROOT); + return lowerPath.endsWith(".md") || lowerPath.endsWith(".txt") + || lowerPath.endsWith(".json") || lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml") + || lowerPath.endsWith(".js") || lowerPath.endsWith(".ts") + || lowerPath.endsWith(".py") || lowerPath.endsWith(".sh") || lowerPath.endsWith(".svg") + || lowerPath.endsWith(".html") || lowerPath.endsWith(".css") || lowerPath.endsWith(".csv") + || lowerPath.endsWith(".toml") || lowerPath.endsWith(".xml") || lowerPath.endsWith(".ini") + || lowerPath.endsWith(".cfg") || lowerPath.endsWith(".env") + || lowerPath.endsWith(".rb") || lowerPath.endsWith(".go") || lowerPath.endsWith(".rs") + || lowerPath.endsWith(".java") || lowerPath.endsWith(".kt") || lowerPath.endsWith(".lua") + || lowerPath.endsWith(".sql") || lowerPath.endsWith(".r") + || lowerPath.endsWith(".bat") || lowerPath.endsWith(".ps1") + || lowerPath.endsWith(".zsh") || lowerPath.endsWith(".bash"); + } + + private boolean isPlaceholderValue(String value) { + if (value == null || value.isBlank()) { + return false; + } + return PLACEHOLDER_VALUE.matcher(value).matches() + || value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-'); + } + + private record SecretRule(Pattern pattern, int valueGroup, String label) {} +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java index 73f821d4..cd8e1ef8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java @@ -1,8 +1,5 @@ package com.iflytek.skillhub.domain.skill.validation; -import org.springframework.stereotype.Component; - -@Component public class NoOpPrePublishValidator implements PrePublishValidator { @Override diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 3c0e5498..c4520a54 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -2,18 +2,29 @@ import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.util.Set; public final class SkillPackagePolicy { public static final int MAX_FILE_COUNT = 100; - public static final long MAX_SINGLE_FILE_SIZE = 1024 * 1024; // 1MB - public static final long MAX_TOTAL_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB + public static final long MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024; // 10MB + public static final long MAX_TOTAL_PACKAGE_SIZE = 100 * 1024 * 1024; // 100MB public static final String SKILL_MD_PATH = "SKILL.md"; public static final Set ALLOWED_EXTENSIONS = Set.of( - ".md", ".txt", ".json", ".yaml", ".yml", - ".js", ".ts", ".py", ".sh", - ".png", ".jpg", ".svg" + // 文档 + ".md", ".txt", ".json", ".yaml", ".yml", ".html", ".css", ".csv", ".pdf", + // 配置 + ".toml", ".xml", ".ini", ".cfg", ".env", + // 脚本/语言 + ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", + ".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash", + // 图片 + ".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico" ); private SkillPackagePolicy() { @@ -53,4 +64,99 @@ public static String normalizeEntryPath(String rawPath) { public static boolean hasAllowedExtension(String path) { return ALLOWED_EXTENSIONS.stream().anyMatch(path::endsWith); } + + public static String validateContentMatchesExtension(String path, byte[] content) { + String lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".png")) { + return hasPrefix(content, (byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) + ? null + : "File content does not match extension: " + path; + } + if (lowerPath.endsWith(".jpg")) { + return hasPrefix(content, (byte) 0xff, (byte) 0xd8, (byte) 0xff) + ? null + : "File content does not match extension: " + path; + } + if (lowerPath.endsWith(".svg")) { + if (!isUtf8Text(content)) { + return "File content does not match extension: " + path; + } + String text = new String(content, StandardCharsets.UTF_8).trim().toLowerCase(); + return text.contains("= 12 + && hasPrefix(content, 'R', 'I', 'F', 'F') + && content[8] == 'W' && content[9] == 'E' && content[10] == 'B' && content[11] == 'P') + ? null + : "File content does not match extension: " + path; + } + if (lowerPath.endsWith(".ico")) { + return hasPrefix(content, 0x00, 0x00, 0x01, 0x00) + ? null + : "File content does not match extension: " + path; + } + if (lowerPath.endsWith(".pdf")) { + return hasPrefix(content, '%', 'P', 'D', 'F') + ? null + : "File content does not match extension: " + path; + } + if (isTextExtension(lowerPath)) { + return isUtf8Text(content) ? null : "File content does not match extension: " + path; + } + return null; + } + + private static boolean isTextExtension(String path) { + return path.endsWith(".md") || path.endsWith(".txt") + || path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml") + || path.endsWith(".js") || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh") + || path.endsWith(".html") || path.endsWith(".css") || path.endsWith(".csv") + || path.endsWith(".toml") || path.endsWith(".xml") || path.endsWith(".ini") + || path.endsWith(".cfg") || path.endsWith(".env") + || path.endsWith(".rb") || path.endsWith(".go") || path.endsWith(".rs") + || path.endsWith(".java") || path.endsWith(".kt") || path.endsWith(".lua") + || path.endsWith(".sql") || path.endsWith(".r") + || path.endsWith(".bat") || path.endsWith(".ps1") + || path.endsWith(".zsh") || path.endsWith(".bash"); + } + + private static boolean isUtf8Text(byte[] content) { + for (byte value : content) { + if (value == 0) { + return false; + } + } + try { + CharBuffer ignored = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(content)); + return true; + } catch (CharacterCodingException ex) { + return false; + } + } + + private static boolean hasPrefix(byte[] content, int... prefix) { + if (content.length < prefix.length) { + return false; + } + for (int index = 0; index < prefix.length; index++) { + if ((content[index] & 0xff) != (prefix[index] & 0xff)) { + return false; + } + } + return true; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 950417fc..a483612c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -7,13 +7,40 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class SkillPackageValidator { + private static final Pattern YAML_LINE_COLUMN = Pattern.compile("line\\s+(\\d+),\\s+column\\s+(\\d+)"); private final SkillMetadataParser metadataParser; + private final int maxFileCount; + private final long maxSingleFileSize; + private final long maxTotalPackageSize; + private final Set allowedExtensions; public SkillPackageValidator(SkillMetadataParser metadataParser) { + this( + metadataParser, + SkillPackagePolicy.MAX_FILE_COUNT, + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE, + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE, + SkillPackagePolicy.ALLOWED_EXTENSIONS + ); + } + + public SkillPackageValidator(SkillMetadataParser metadataParser, + int maxFileCount, + long maxSingleFileSize, + long maxTotalPackageSize, + Set allowedExtensions) { this.metadataParser = metadataParser; + this.maxFileCount = maxFileCount; + this.maxSingleFileSize = maxSingleFileSize; + this.maxTotalPackageSize = maxTotalPackageSize; + this.allowedExtensions = allowedExtensions.stream() + .map(String::toLowerCase) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); } public ValidationResult validate(List entries) { @@ -34,10 +61,15 @@ public ValidationResult validate(List entries) { errors.add("Duplicate package entry path: " + normalizedPath); } - if (!SkillPackagePolicy.hasAllowedExtension(normalizedPath)) { + if (!hasAllowedExtension(normalizedPath)) { errors.add("Disallowed file extension: " + normalizedPath); } + String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content()); + if (contentMismatch != null) { + errors.add(contentMismatch); + } + if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) { skillMd = entry; } @@ -54,30 +86,65 @@ public ValidationResult validate(List entries) { String content = new String(skillMd.content()); metadataParser.parse(content); } catch (LocalizedDomainException e) { - String detail = e.messageArgs().length == 0 - ? e.messageCode() - : e.messageCode() + " " + java.util.Arrays.toString(e.messageArgs()); - errors.add("Invalid SKILL.md frontmatter: " + detail); + errors.add("Invalid SKILL.md frontmatter: " + formatMetadataError(e)); } // 3. Check file count - if (entries.size() > SkillPackagePolicy.MAX_FILE_COUNT) { - errors.add("Too many files: " + entries.size() + " (max: " + SkillPackagePolicy.MAX_FILE_COUNT + ")"); + if (entries.size() > maxFileCount) { + errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")"); } // 4. Check single file size for (PackageEntry entry : entries) { - if (entry.size() > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { - errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")"); + if (entry.size() > maxSingleFileSize) { + errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")"); } } // 5. Check total package size long totalSize = entries.stream().mapToLong(PackageEntry::size).sum(); - if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { - errors.add("Package too large: " + totalSize + " bytes (max: " + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")"); + if (totalSize > maxTotalPackageSize) { + errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); } return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); } + + private boolean hasAllowedExtension(String normalizedPath) { + return allowedExtensions.stream().anyMatch(normalizedPath::endsWith); + } + + private String formatMetadataError(LocalizedDomainException exception) { + return switch (exception.messageCode()) { + case "error.skill.metadata.requiredField.missing" -> + "missing required field \"" + exception.messageArgs()[0] + "\""; + case "error.skill.metadata.frontmatter.missingStart" -> + "missing opening --- marker"; + case "error.skill.metadata.frontmatter.missingEnd" -> + "missing closing --- marker"; + case "error.skill.metadata.frontmatter.missingContent" -> + "frontmatter is empty"; + case "error.skill.metadata.yaml.notMap" -> + "frontmatter must be a YAML object"; + case "error.skill.metadata.yaml.invalid" -> + formatYamlSyntaxError(exception.messageArgs()); + default -> { + if (exception.messageArgs().length == 0) { + yield exception.messageCode(); + } + yield exception.messageCode() + " " + java.util.Arrays.toString(exception.messageArgs()); + } + }; + } + + private String formatYamlSyntaxError(Object[] args) { + String raw = args.length > 0 && args[0] != null ? args[0].toString() : ""; + Matcher matcher = YAML_LINE_COLUMN.matcher(raw); + if (matcher.find()) { + return "invalid YAML near line " + matcher.group(1) + + ", column " + matcher.group(2) + + ". If a value contains a colon, wrap it in quotes."; + } + return "invalid YAML syntax"; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java index 8d496c63..b38850ca 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java @@ -2,7 +2,8 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_rating", @@ -21,10 +22,10 @@ public class SkillRating { private Short score; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt = LocalDateTime.now(); + private Instant updatedAt; protected SkillRating() {} @@ -38,7 +39,18 @@ public SkillRating(Long skillId, String userId, short score) { public void updateScore(short newScore) { if (newScore < 1 || newScore > 5) throw new DomainBadRequestException("error.rating.score.invalid"); this.score = newScore; - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = Instant.now(Clock.systemUTC()); } // getters @@ -46,6 +58,6 @@ public void updateScore(short newScore) { public Long getSkillId() { return skillId; } public String getUserId() { return userId; } public Short getScore() { return score; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 814c1ff7..7d8a6235 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -11,16 +13,20 @@ @Service public class SkillRatingService { private final SkillRatingRepository ratingRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillRatingService(SkillRatingRepository ratingRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.ratingRepository = ratingRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void rate(Long skillId, String userId, short score) { + ensureSkillExists(skillId); if (score < 1 || score > 5) { throw new DomainBadRequestException("error.rating.score.invalid"); } @@ -35,7 +41,14 @@ public void rate(Long skillId, String userId, short score) { } public Optional getUserRating(Long skillId, String userId) { + ensureSkillExists(skillId); return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java index b87571c4..3dfcf09d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.social; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_star", @@ -17,7 +18,7 @@ public class SkillStar { private String userId; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt; protected SkillStar() {} @@ -26,9 +27,14 @@ public SkillStar(Long skillId, String userId) { this.userId = userId; } + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + } + // getters public Long getId() { return id; } public Long getSkillId() { return skillId; } public String getUserId() { return userId; } - public LocalDateTime getCreatedAt() { return createdAt; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java index 2576d457..b1dd49bc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.springframework.context.ApplicationEventPublisher; @@ -9,16 +11,20 @@ @Service public class SkillStarService { private final SkillStarRepository starRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillStarService(SkillStarRepository starRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.starRepository = starRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void star(Long skillId, String userId) { + ensureSkillExists(skillId); if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { return; // idempotent } @@ -28,6 +34,7 @@ public void star(Long skillId, String userId) { @Transactional public void unstar(Long skillId, String userId) { + ensureSkillExists(skillId); starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> { starRepository.delete(star); eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId)); @@ -35,6 +42,13 @@ public void unstar(Long skillId, String userId) { } public boolean isStarred(Long skillId, String userId) { + ensureSkillExists(skillId); return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java index 2c505a05..0ef2c569 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.user; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "user_account") @@ -27,10 +28,10 @@ public class UserAccount { private String mergedToUserId; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected UserAccount() {} @@ -44,13 +45,13 @@ public UserAccount(String id, String displayName, String email, String avatarUrl @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public String getId() { return id; } @@ -64,7 +65,7 @@ void preUpdate() { public void setStatus(UserStatus status) { this.status = status; } public String getMergedToUserId() { return mergedToUserId; } public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } public boolean isActive() { return this.status == UserStatus.ACTIVE; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index d9448334..d2163c56 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -1,8 +1,15 @@ package com.iflytek.skillhub.domain.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; import java.util.Optional; public interface UserAccountRepository { Optional findById(String id); + List findByIdIn(List ids); + Optional findByEmailIgnoreCase(String email); + Page search(String keyword, UserStatus status, Pageable pageable); UserAccount save(UserAccount user); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogServiceTest.java new file mode 100644 index 00000000..437cfa91 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogServiceTest.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.domain.audit; + +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogServiceTest { + + @Mock + private AuditLogRepository auditLogRepository; + + private AuditLogService auditLogService; + private Clock clock; + + @BeforeEach + void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-18T02:03:04Z"), ZoneOffset.UTC); + auditLogService = new AuditLogService(auditLogRepository, clock); + } + + @Test + void record_usesInjectedClockForCreatedAt() { + when(auditLogRepository.save(any(AuditLog.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AuditLog result = auditLogService.record( + "user-1", + "SKILL_PUBLISH", + "SKILL", + 7L, + "req-1", + "127.0.0.1", + "JUnit", + "{\"version\":\"1.0.0\"}" + ); + + assertThat(result.getCreatedAt()).isEqualTo(Instant.now(clock)); + assertThat(result.getAction()).isEqualTo("SKILL_PUBLISH"); + assertThat(result.getTargetId()).isEqualTo(7L); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java new file mode 100644 index 00000000..7e45589e --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java @@ -0,0 +1,114 @@ +package com.iflytek.skillhub.domain.governance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class GovernanceNotificationServiceTest { + + @Mock + private UserNotificationRepository userNotificationRepository; + + private GovernanceNotificationService service; + private Clock clock; + + @BeforeEach + void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-18T01:02:03Z"), ZoneOffset.UTC); + service = new GovernanceNotificationService(userNotificationRepository, clock); + } + + @Test + void notifyUser_createsUnreadNotification() { + when(userNotificationRepository.save(any(UserNotification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + UserNotification notification = service.notifyUser( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}" + ); + + assertThat(notification.getUserId()).isEqualTo("user-1"); + assertThat(notification.getStatus()).isEqualTo(UserNotificationStatus.UNREAD); + assertThat(notification.getCategory()).isEqualTo("REVIEW"); + assertThat(notification.getCreatedAt()).isEqualTo(Instant.now(clock)); + } + + @Test + void markRead_requiresOwner() { + UserNotification notification = new UserNotification( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}", + Instant.parse("2026-03-18T00:00:00Z") + ); + setField(notification, "id", 10L); + when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + + assertThrows(DomainForbiddenException.class, () -> service.markRead(10L, "user-2")); + } + + @Test + void listNotifications_returnsNewestFirst() { + UserNotification unread = new UserNotification("user-1", "REVIEW", "REVIEW_TASK", 99L, "A", "{}", Instant.parse("2026-03-18T00:00:00Z")); + UserNotification read = new UserNotification("user-1", "REPORT", "SKILL_REPORT", 88L, "B", "{}", Instant.parse("2026-03-18T00:01:00Z")); + when(userNotificationRepository.findByUserIdOrderByCreatedAtDesc("user-1")).thenReturn(List.of(unread, read)); + + List result = service.listNotifications("user-1"); + + assertThat(result).hasSize(2); + } + + @Test + void markRead_setsReadTimestampFromClock() { + UserNotification notification = new UserNotification( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}", + Instant.parse("2026-03-18T00:00:00Z") + ); + setField(notification, "id", 10L); + when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + when(userNotificationRepository.save(any(UserNotification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + UserNotification result = service.markRead(10L, "user-1"); + + assertThat(result.getStatus()).isEqualTo(UserNotificationStatus.READ); + assertThat(result.getReadAt()).isEqualTo(Instant.now(clock)); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java new file mode 100644 index 00000000..c4e211bb --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -0,0 +1,71 @@ +package com.iflytek.skillhub.domain.namespace; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.lang.reflect.Field; +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; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class GlobalNamespaceMembershipServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + private GlobalNamespaceMembershipService service; + + @BeforeEach + void setUp() { + service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); + } + + @Test + void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setNamespaceId(global, 1L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo("usr_1"); + assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.MEMBER); + } + + @Test + void ensureMember_keepsExistingGlobalMembership() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setNamespaceId(global, 1L); + NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, never()).save(any()); + } + + private void setNamespaceId(Namespace namespace, Long id) throws Exception { + Field field = Namespace.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(namespace, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java new file mode 100644 index 00000000..6e5e9f69 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.domain.namespace; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class NamespaceAccessPolicyTest { + + private final NamespaceAccessPolicy policy = new NamespaceAccessPolicy(); + + @Test + void globalNamespaceIsImmutable() { + Namespace namespace = new Namespace("global", "Global", "owner"); + namespace.setType(NamespaceType.GLOBAL); + + assertThat(policy.isImmutable(namespace)).isTrue(); + assertThat(policy.canMutateSettings(namespace)).isFalse(); + assertThat(policy.canManageMembers(namespace)).isFalse(); + assertThat(policy.canTransferOwnership(namespace)).isFalse(); + } + + @Test + void activeTeamNamespaceAllowsAdminAndOwnerToFreezeButNotMember() { + Namespace namespace = new Namespace("team-a", "Team A", "owner"); + namespace.setType(NamespaceType.TEAM); + namespace.setStatus(NamespaceStatus.ACTIVE); + + assertThat(policy.canFreeze(namespace, NamespaceRole.OWNER)).isTrue(); + assertThat(policy.canFreeze(namespace, NamespaceRole.ADMIN)).isTrue(); + assertThat(policy.canFreeze(namespace, NamespaceRole.MEMBER)).isFalse(); + } + + @Test + void frozenTeamNamespaceAllowsAdminAndOwnerToUnfreezeButNotMember() { + Namespace namespace = new Namespace("team-a", "Team A", "owner"); + namespace.setType(NamespaceType.TEAM); + namespace.setStatus(NamespaceStatus.FROZEN); + + assertThat(policy.canUnfreeze(namespace, NamespaceRole.OWNER)).isTrue(); + assertThat(policy.canUnfreeze(namespace, NamespaceRole.ADMIN)).isTrue(); + assertThat(policy.canUnfreeze(namespace, NamespaceRole.MEMBER)).isFalse(); + } + + @Test + void archiveAndRestoreAreOwnerOnly() { + Namespace namespace = new Namespace("team-a", "Team A", "owner"); + namespace.setType(NamespaceType.TEAM); + namespace.setStatus(NamespaceStatus.ACTIVE); + + assertThat(policy.canArchive(namespace, NamespaceRole.OWNER)).isTrue(); + assertThat(policy.canArchive(namespace, NamespaceRole.ADMIN)).isFalse(); + + namespace.setStatus(NamespaceStatus.ARCHIVED); + assertThat(policy.canRestore(namespace, NamespaceRole.OWNER)).isTrue(); + assertThat(policy.canRestore(namespace, NamespaceRole.ADMIN)).isFalse(); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java new file mode 100644 index 00000000..65290270 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceGovernanceServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + + @Mock + private AuditLogService auditLogService; + + @InjectMocks + private NamespaceGovernanceService governanceService; + + @Test + void freezeNamespace_allowsAdminOnActiveTeamNamespace() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canFreeze(namespace, NamespaceRole.ADMIN)).thenReturn(true); + when(namespaceRepository.save(namespace)).thenReturn(namespace); + + Namespace updated = governanceService.freezeNamespace("team-a", "admin-1", null, null, null, null); + + assertEquals(NamespaceStatus.FROZEN, updated.getStatus()); + verify(namespaceRepository).save(namespace); + } + + @Test + void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canArchive(namespace, NamespaceRole.ADMIN)).thenReturn(false); + + assertThrows(DomainForbiddenException.class, + () -> governanceService.archiveNamespace("team-a", "admin-1", "cleanup", null, null, null)); + } + + @Test + void restoreNamespace_movesArchivedNamespaceBackToActive() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canRestore(namespace, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceRepository.save(namespace)).thenReturn(namespace); + + Namespace updated = governanceService.restoreNamespace("team-a", "owner-1", null, null, null); + + assertEquals(NamespaceStatus.ACTIVE, updated.getStatus()); + } + + @Test + void freezeNamespace_rejectsGlobalNamespace() { + Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, + () -> governanceService.freezeNamespace("global", "admin-1", null, null, null, null)); + } + + @Test + void unfreezeNamespace_rejectsIllegalTransition() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, + () -> governanceService.unfreezeNamespace("team-a", "owner-1", null, null, null)); + } + + private Namespace namespace(Long id, String slug, NamespaceType type, NamespaceStatus status) { + Namespace namespace = new Namespace(slug, "Team A", "owner-1"); + setField(namespace, "id", id); + namespace.setType(type); + namespace.setStatus(status); + return namespace; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 380c221e..84d823c1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -20,6 +20,8 @@ class NamespaceMemberServiceTest { private NamespaceMemberRepository namespaceMemberRepository; @Mock private NamespaceService namespaceService; + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; @InjectMocks private NamespaceMemberService namespaceMemberService; @@ -29,7 +31,10 @@ void addMember_shouldAddMemberSuccessfully() { Long namespaceId = 1L; String userId = "user-2"; NamespaceRole role = NamespaceRole.MEMBER; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.empty()); when(namespaceMemberRepository.save(any(NamespaceMember.class))) @@ -43,12 +48,19 @@ void addMember_shouldAddMemberSuccessfully() { @Test void addMember_shouldThrowExceptionForOwnerRole() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.addMember(1L, "user-2", NamespaceRole.OWNER, "user-99")); } @Test void addMember_shouldRequireAdminOrOwner() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); doThrow(new DomainForbiddenException("error.namespace.admin.required")).when(namespaceService).assertAdminOrOwner(1L, "user-99"); assertThrows(DomainForbiddenException.class, () -> @@ -59,6 +71,9 @@ void addMember_shouldRequireAdminOrOwner() { void addMember_shouldThrowExceptionWhenMemberExists() { Long namespaceId = 1L; String userId = "user-2"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(new NamespaceMember())); @@ -66,11 +81,27 @@ void addMember_shouldThrowExceptionWhenMemberExists() { namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER, "user-99")); } + @Test + void addMember_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.addMember(namespaceId, "user-2", NamespaceRole.MEMBER, "user-99")); + } + @Test void removeMember_shouldThrowExceptionForOwner() { Long namespaceId = 1L; String userId = "user-2"; NamespaceMember ownerMember = new NamespaceMember(namespaceId, userId, NamespaceRole.OWNER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(ownerMember)); @@ -80,6 +111,9 @@ void removeMember_shouldThrowExceptionForOwner() { @Test void removeMember_shouldThrowExceptionWhenMemberNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -87,11 +121,27 @@ void removeMember_shouldThrowExceptionWhenMemberNotFound() { namespaceMemberService.removeMember(1L, "user-2", "user-99")); } + @Test + void updateMemberRole_shouldRejectArchivedNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.updateMemberRole(namespaceId, "user-2", NamespaceRole.ADMIN, "user-99")); + } + @Test void updateMemberRole_shouldUpdateRoleSuccessfully() { Long namespaceId = 1L; String userId = "user-2"; NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(member)); when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenReturn(member); @@ -106,6 +156,9 @@ void updateMemberRole_shouldUpdateRoleSuccessfully() { void updateMemberRole_shouldThrowExceptionForOwnerRole() { Long namespaceId = 1L; String userId = "user-2"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER, "user-99")); @@ -113,6 +166,9 @@ void updateMemberRole_shouldThrowExceptionForOwnerRole() { @Test void updateMemberRole_shouldThrowExceptionWhenMemberNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -128,7 +184,10 @@ void transferOwnership_shouldTransferOwnershipSuccessfully() { NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); NamespaceMember newOwner = new NamespaceMember(namespaceId, newOwnerId, NamespaceRole.ADMIN); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(currentOwner)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) @@ -143,6 +202,9 @@ void transferOwnership_shouldTransferOwnershipSuccessfully() { @Test void transferOwnership_shouldThrowExceptionWhenCurrentOwnerNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -155,6 +217,9 @@ void transferOwnership_shouldThrowExceptionWhenCurrentUserIsNotOwner() { Long namespaceId = 1L; String currentOwnerId = "user-2"; NamespaceMember notOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.ADMIN); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(notOwner)); @@ -168,7 +233,10 @@ void transferOwnership_shouldThrowExceptionWhenNewOwnerNotFound() { String currentOwnerId = "user-2"; String newOwnerId = "user-3"; NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(currentOwner)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) @@ -178,6 +246,18 @@ void transferOwnership_shouldThrowExceptionWhenNewOwnerNotFound() { namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, newOwnerId)); } + @Test + void transferOwnership_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.transferOwnership(namespaceId, "user-2", "user-3")); + } + @Test void getMemberRole_shouldReturnRole() { Long namespaceId = 1L; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index ecb8d18b..48953f90 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -23,6 +23,9 @@ class NamespaceServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + @InjectMocks private NamespaceService namespaceService; @@ -70,6 +73,8 @@ void updateNamespace_shouldUpdateFields() { when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(true); when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace); Namespace result = namespaceService.updateNamespace( @@ -105,6 +110,51 @@ void updateNamespace_shouldThrowExceptionWhenOperatorLacksPrivilege() { namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); } + @Test + void updateNamespace_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("slug", "Old Name", "user-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + + @Test + void updateNamespace_shouldRejectGlobalNamespaceMutation() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("global", "Global", "system"); + namespace.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + + @Test + void updateNamespace_shouldRejectGlobalNamespaceMutationBeforeMembershipChecks() { + Long namespaceId = 1L; + String operatorUserId = "user-404"; + Namespace namespace = new Namespace("global", "Global", "system"); + namespace.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + + assertEquals("error.namespace.system.immutable", exception.messageCode()); + verify(namespaceMemberRepository, never()).findByNamespaceIdAndUserId(namespaceId, operatorUserId); + } + @Test void getNamespaceBySlug_shouldReturnNamespace() { String slug = "test-slug"; @@ -124,4 +174,25 @@ void getNamespaceBySlug_shouldThrowExceptionWhenNotFound() { assertThrows(DomainBadRequestException.class, () -> namespaceService.getNamespaceBySlug("nonexistent")); } + + @Test + void assertMember_shouldAllowExistingMember() { + Long namespaceId = 1L; + String userId = "user-1"; + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER))); + + assertDoesNotThrow(() -> namespaceService.assertMember(namespaceId, userId)); + } + + @Test + void assertMember_shouldRejectNonMember() { + Long namespaceId = 1L; + String userId = "user-404"; + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) + .thenReturn(Optional.empty()); + + assertThrows(DomainForbiddenException.class, () -> + namespaceService.assertMember(namespaceId, userId)); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java new file mode 100644 index 00000000..86df099e --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -0,0 +1,171 @@ +package com.iflytek.skillhub.domain.report; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class SkillReportServiceTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T08:00:00Z"), ZoneOffset.UTC); + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillReportRepository skillReportRepository; + + @Mock + private AuditLogService auditLogService; + + @Mock + private SkillGovernanceService skillGovernanceService; + + @Mock + private GovernanceNotificationService governanceNotificationService; + + private SkillReportService service; + + @BeforeEach + void setUp() { + service = new SkillReportService( + skillRepository, + skillReportRepository, + auditLogService, + skillGovernanceService, + governanceNotificationService, + CLOCK + ); + } + + @Test + void submitReport_createsPendingReport() { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(false); + when(skillReportRepository.save(any(SkillReport.class))).thenAnswer(invocation -> { + SkillReport report = invocation.getArgument(0); + setField(report, "id", 99L); + return report; + }); + + SkillReport report = service.submitReport(10L, "user-1", "Inappropriate content", "details", "127.0.0.1", "JUnit"); + + assertThat(report.getStatus()).isEqualTo(SkillReportStatus.PENDING); + assertThat(report.getReason()).isEqualTo("Inappropriate content"); + verify(auditLogService).record("user-1", "REPORT_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reportId\":99}"); + } + + @Test + void submitReport_rejectsDuplicatePendingReport() { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, + () -> service.submitReport(10L, "user-1", "Inappropriate content", null, "127.0.0.1", "JUnit")); + } + + @Test + void submitReport_rejectsSelfReport() { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + + assertThrows(DomainBadRequestException.class, + () -> service.submitReport(10L, "owner", "Inappropriate content", null, "127.0.0.1", "JUnit")); + } + + @Test + void resolveReport_marksReportResolved() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + SkillReport saved = service.resolveReport(99L, "admin", "handled", "127.0.0.1", "JUnit"); + + assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); + assertThat(saved.getHandledBy()).isEqualTo("admin"); + assertThat(saved.getHandledAt()).isEqualTo(Instant.now(CLOCK)); + } + + @Test + void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + SkillReport saved = service.resolveReport( + 99L, + "admin", + SkillReportDisposition.RESOLVE_AND_HIDE, + "handled", + "127.0.0.1", + "JUnit" + ); + + assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); + verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled"); + verify(governanceNotificationService).notifyUser( + eq("user-1"), + eq("REPORT"), + eq("SKILL_REPORT"), + eq(99L), + eq("Report handled"), + any() + ); + } + + @Test + void resolveReport_withArchiveDisposition_archivesSkill() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + service.resolveReport( + 99L, + "admin", + SkillReportDisposition.RESOLVE_AND_ARCHIVE, + "handled", + "127.0.0.1", + "JUnit" + ); + + verify(skillGovernanceService).archiveSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled"); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index b1a8ea76..31695868 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -1,8 +1,10 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -17,6 +19,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -27,6 +32,8 @@ @ExtendWith(MockitoExtension.class) class PromotionServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T11:00:00Z"), ZoneOffset.UTC); + @Mock private PromotionRequestRepository promotionRequestRepository; @Mock private SkillRepository skillRepository; @Mock private SkillVersionRepository skillVersionRepository; @@ -34,6 +41,7 @@ class PromotionServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private GovernanceNotificationService governanceNotificationService; private PromotionService promotionService; @@ -50,7 +58,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, CLOCK); } private static void setField(Object target, String fieldName, Object value) { @@ -97,6 +105,12 @@ private Namespace createTeamNamespace() { return ns; } + private Namespace createSourceNamespace() { + Namespace ns = new Namespace("team-a", "Team A", "user-1"); + setField(ns, "id", 5L); + return ns; + } + private PromotionRequest createPendingPromotion() { PromotionRequest pr = new PromotionRequest(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID); setField(pr, "id", PROMOTION_ID); @@ -121,9 +135,12 @@ void shouldSubmitPromotionSuccessfully() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); - when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) .thenReturn(Optional.empty()); when(promotionRequestRepository.save(any(PromotionRequest.class))) .thenAnswer(inv -> { @@ -191,6 +208,7 @@ void shouldThrowWhenTargetNamespaceNotFound() { Skill sourceSkill = createSourceSkill(); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty()); @@ -203,6 +221,7 @@ void shouldThrowWhenTargetNamespaceNotGlobal() { Skill sourceSkill = createSourceSkill(); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace())); @@ -215,15 +234,36 @@ void shouldThrowWhenDuplicatePendingExists() { Skill sourceSkill = createSourceSkill(); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); - when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(createPendingPromotion())); assertThrows(DomainBadRequestException.class, () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); } + @Test + void shouldThrowWhenSkillAlreadyPromoted() { + Skill sourceSkill = createSourceSkill(); + PromotionRequest approvedPromotion = createPendingPromotion(); + setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); + + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); + when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); + when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) + .thenReturn(Optional.of(approvedPromotion)); + + assertThrows(DomainBadRequestException.class, + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); + } + @Test void shouldThrowWhenSubmitterIsNotOwnerOrNamespaceAdmin() { Skill sourceSkill = createSourceSkill(); @@ -231,6 +271,7 @@ void shouldThrowWhenSubmitterIsNotOwnerOrNamespaceAdmin() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion( sourceSkill, "user-999", @@ -256,13 +297,16 @@ void shouldAllowNamespaceAdminToSubmitPromotionForForeignSkill() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion( sourceSkill, "user-999", Map.of(sourceSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN))) .thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); - when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) .thenReturn(Optional.empty()); when(promotionRequestRepository.save(any(PromotionRequest.class))) .thenAnswer(inv -> inv.getArgument(0)); @@ -277,6 +321,68 @@ void shouldAllowNamespaceAdminToSubmitPromotionForForeignSkill() { assertNotNull(result); } + + @Test + void shouldRejectSubmitWhenSourceNamespaceFrozen() { + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Namespace sourceNamespace = new Namespace("team-a", "Team A", "user-1"); + setField(sourceNamespace, "id", sourceSkill.getNamespaceId()); + sourceNamespace.setStatus(NamespaceStatus.FROZEN); + + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(sourceNamespace)); + + assertThrows(DomainBadRequestException.class, + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); + } + } + + @Nested + class ReviewPromotion { + + @Test + void shouldNotifySubmitterWhenPromotionApproved() { + PromotionRequest request = createPendingPromotion(); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", REVIEWER_ID, SkillVisibility.PUBLIC); + setField(newSkill, "id", NEW_SKILL_ID); + SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), REVIEWER_ID); + setField(newVersion, "id", NEW_VERSION_ID); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", null, request.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + + promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); + } + + @Test + void shouldNotifySubmitterWhenPromotionRejected() { + PromotionRequest request = createPendingPromotion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "no", null, request.getVersion())) + .thenReturn(1); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + + promotionService.rejectPromotion(PROMOTION_ID, REVIEWER_ID, "no", Set.of("SKILL_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); + } } @Nested @@ -285,11 +391,17 @@ class ApprovePromotion { @Test void shouldApprovePromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); + PromotionRequest approvedPromotion = createPendingPromotion(); + setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); + setField(approvedPromotion, "version", 2); + setField(approvedPromotion, "reviewedBy", REVIEWER_ID); + setField(approvedPromotion, "reviewComment", "LGTM"); Skill sourceSkill = createSourceSkill(); SkillVersion sourceVersion = createPublishedVersion(); List sourceFiles = createSourceFiles(); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(pr), Optional.of(approvedPromotion)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) @@ -308,6 +420,7 @@ void shouldApprovePromotionSuccessfully() { }); when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(sourceFiles); when(skillFileRepository.saveAll(anyList())).thenAnswer(inv -> inv.getArgument(0)); + when(promotionRequestRepository.save(approvedPromotion)).thenReturn(approvedPromotion); PromotionRequest result = promotionService.approvePromotion( PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN")); @@ -336,7 +449,7 @@ void shouldApprovePromotionSuccessfully() { assertEquals("{\"version\":\"1.0.0\"}", newVersion.getManifestJson()); assertEquals(3, newVersion.getFileCount()); assertEquals(1024L, newVersion.getTotalSize()); - assertNotNull(newVersion.getPublishedAt()); + assertEquals(Instant.now(CLOCK), newVersion.getPublishedAt()); // Verify files copied @SuppressWarnings("unchecked") @@ -354,8 +467,8 @@ void shouldApprovePromotionSuccessfully() { assertEquals(REVIEWER_ID, event.publisherId()); // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(pr); - assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); + verify(promotionRequestRepository).save(approvedPromotion); + assertEquals(NEW_SKILL_ID, approvedPromotion.getTargetSkillId()); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 48a3aadd..21fc18dc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -18,13 +18,45 @@ class ReviewPermissionCheckerTest { // --- canReview tests --- @Test - void cannotReviewOwnSubmission() { + void regularUserCannotReviewOwnSubmission() { String userId = "user-1"; ReviewTask task = new ReviewTask(1L, 10L, userId); assertFalse(checker.canReview(task, userId, NamespaceType.TEAM, Map.of(), Set.of())); } + @Test + void skillAdminCannotReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertFalse(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); + } + + @Test + void superAdminCannotReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(), Set.of("SUPER_ADMIN"))); + } + + @Test + void regularUserCannotReviewOwnGlobalSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 1L, userId); + assertFalse(checker.canReview(task, userId, + NamespaceType.GLOBAL, Map.of(), Set.of())); + } + + @Test + void superAdminCanReviewOwnGlobalSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 1L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void teamAdminCanReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); @@ -68,11 +100,19 @@ void superAdminCanReviewGlobalSkill() { @Test void skillAdminCannotReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); - assertFalse(checker.canReview(task, "user-1", + assertTrue(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } + @Test + void superAdminCanReviewTeamSkill() { + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertTrue(checker.canReview(task, "user-1", + NamespaceType.TEAM, + Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void nonAdminCannotReviewGlobalSkill() { ReviewTask task = new ReviewTask(1L, 1L, "user-2"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index fd6815ae..85babd55 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -2,9 +2,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; 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.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -14,6 +16,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -25,10 +28,14 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; +import java.time.Clock; +import java.time.Instant; import java.util.ConcurrentModificationException; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.time.ZoneOffset; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -37,12 +44,16 @@ @ExtendWith(MockitoExtension.class) class ReviewServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T10:00:00Z"), ZoneOffset.UTC); + @Mock private ReviewTaskRepository reviewTaskRepository; @Mock private SkillVersionRepository skillVersionRepository; @Mock private SkillRepository skillRepository; @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private SkillGovernanceService skillGovernanceService; + @Mock private GovernanceNotificationService governanceNotificationService; private ReviewService reviewService; @@ -59,7 +70,7 @@ void setUp() { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, CLOCK); } private SkillVersion createDraftSkillVersion() { @@ -109,8 +120,10 @@ class SubmitReview { void shouldSubmitReviewSuccessfully() { SkillVersion sv = createDraftSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(permissionChecker.canSubmitReview( NAMESPACE_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))).thenReturn(true); @@ -140,8 +153,10 @@ void shouldThrowWhenSkillVersionNotFound() { @Test void shouldThrowWhenStatusNotDraft() { SkillVersion sv = createPendingReviewSkillVersion(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); assertThrows(DomainBadRequestException.class, () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))); @@ -151,8 +166,10 @@ void shouldThrowWhenStatusNotDraft() { void shouldThrowOnDuplicateSubmission() { SkillVersion sv = createDraftSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(permissionChecker.canSubmitReview( NAMESPACE_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))).thenReturn(true); @@ -171,14 +188,30 @@ void shouldThrowOnDuplicateSubmission() { void shouldThrowWhenSubmitterLacksNamespaceMembership() { SkillVersion sv = createDraftSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(permissionChecker.canSubmitReview(NAMESPACE_ID, Map.of())).thenReturn(false); assertThrows(DomainForbiddenException.class, () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of())); verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); } + + @Test + void shouldRejectSubmitWhenNamespaceFrozen() { + SkillVersion sv = createDraftSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + namespace.setStatus(NamespaceStatus.FROZEN); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))); + } } @Nested @@ -206,6 +239,7 @@ void shouldApproveReviewSuccessfully() { .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.approveReview( @@ -214,12 +248,13 @@ void shouldApproveReviewSuccessfully() { assertNotNull(result); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); - assertNotNull(sv.getPublishedAt()); + assertEquals(Instant.now(CLOCK), sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); assertEquals("Approved Name", skill.getDisplayName()); assertEquals("Approved Summary", skill.getSummary()); assertEquals(REVIEWER_ID, skill.getUpdatedBy()); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review approved"), any()); } @Test @@ -235,6 +270,7 @@ void shouldPublishCorrectEvent() { when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", @@ -248,6 +284,28 @@ void shouldPublishCorrectEvent() { assertEquals(REVIEWER_ID, event.publisherId()); } + @Test + void shouldNotifySubmitterWhenRejected() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(eq(task), eq(REVIEWER_ID), eq(ns.getType()), anyMap(), anySet())) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Needs work", task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any()); + } + @Test void shouldThrowWhenReviewTaskNotFound() { when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.empty()); @@ -266,6 +324,24 @@ void shouldThrowWhenNotPending() { () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of())); } + @Test + void shouldRejectApproveWhenSkillVersionWasWithdrawnBackToDraft() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + sv.setStatus(SkillVersionStatus.DRAFT); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of())); + } + @Test void shouldThrowWhenNoPermission() { ReviewTask task = createPendingReviewTask(); @@ -278,6 +354,45 @@ void shouldThrowWhenNoPermission() { () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of())); } + @Test + void shouldRejectApproveWhenNamespaceFrozen() { + ReviewTask task = createPendingReviewTask(); + Namespace namespace = createTeamNamespace(); + namespace.setStatus(NamespaceStatus.FROZEN); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of())); + } + + @Test + void superAdminCanApproveOwnSubmission() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(eq(task), eq(USER_ID), eq(ns.getType()), anyMap(), eq(Set.of("SUPER_ADMIN")))) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, ReviewTaskStatus.APPROVED, USER_ID, "self approved", task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + ReviewTask result = reviewService.approveReview( + REVIEW_TASK_ID, USER_ID, "self approved", Map.of(), Set.of("SUPER_ADMIN")); + + assertNotNull(result); + assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); + assertEquals(USER_ID, skill.getUpdatedBy()); + } + @Test void shouldThrowOnConcurrentModification() { ReviewTask task = createPendingReviewTask(); @@ -291,6 +406,33 @@ void shouldThrowOnConcurrentModification() { () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of())); } + + @Test + void shouldRejectApproveWhenOtherOwnerHasPublishedSameSlug() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); // owned by USER_ID + + // Another owner's skill with same slug that has a published version + Skill otherSkill = new Skill(NAMESPACE_ID, "my-skill", "other-user", SkillVisibility.PUBLIC); + setField(otherSkill, "id", 99L); + SkillVersion otherPublished = new SkillVersion(99L, "1.0.0", "other-user"); + otherPublished.setStatus(SkillVersionStatus.PUBLISHED); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill, otherSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(99L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(otherPublished)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of())); + } } @Nested @@ -343,6 +485,29 @@ void shouldThrowWhenNoPermission() { Map.of(), Set.of())); } + @Test + void superAdminCanRejectOwnSubmission() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(eq(task), eq(USER_ID), eq(ns.getType()), anyMap(), eq(Set.of("SUPER_ADMIN")))) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, USER_ID, "self rejected", task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + ReviewTask result = reviewService.rejectReview( + REVIEW_TASK_ID, USER_ID, "self rejected", Map.of(), Set.of("SUPER_ADMIN")); + + assertNotNull(result); + assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); + } + @Test void shouldThrowOnConcurrentModification() { ReviewTask task = createPendingReviewTask(); @@ -365,16 +530,22 @@ class WithdrawReview { void shouldWithdrawReviewSuccessfully() { ReviewTask task = createPendingReviewTask(); SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + SkillVersion withdrawn = createDraftSkillVersion(); when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(task)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(withdrawn); - reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); + SkillVersion result = reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); + assertEquals(SkillVersionStatus.DRAFT, result.getStatus()); verify(reviewTaskRepository).delete(task); - assertEquals(SkillVersionStatus.DRAFT, sv.getStatus()); - verify(skillVersionRepository).save(sv); + verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID); } @Test @@ -396,5 +567,68 @@ void shouldThrowWhenNotSubmitter() { assertThrows(DomainForbiddenException.class, () -> reviewService.withdrawReview(SKILL_VERSION_ID, otherUserId)); } + + @Test + void shouldRejectWithdrawWhenNamespaceArchived() { + ReviewTask task = createPendingReviewTask(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + namespace.setStatus(NamespaceStatus.ARCHIVED); + + when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID)); + } + + @Test + void shouldReturnDraftVersionWhenOnlyPendingVersionExists() { + ReviewTask task = createPendingReviewTask(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + SkillVersion withdrawn = createDraftSkillVersion(); + + when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(withdrawn); + + SkillVersion result = reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); + + assertEquals(SkillVersionStatus.DRAFT, result.getStatus()); + verify(reviewTaskRepository).delete(task); + verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID); + } + + @Test + void shouldWithdrawPendingVersionAndKeepSkillWhenPublishedHistoryExists() { + ReviewTask task = createPendingReviewTask(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + setField(skill, "latestVersionId", 99L); + SkillVersion withdrawn = createDraftSkillVersion(); + + when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(withdrawn); + + SkillVersion result = reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); + + assertEquals(SkillVersionStatus.DRAFT, result.getStatus()); + verify(reviewTaskRepository).delete(task); + verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID); + } } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 4d273345..1ffe5c8f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -14,6 +14,8 @@ class VisibilityCheckerTest { private Skill publicSkill; private Skill namespaceOnlySkill; private Skill privateSkill; + private Skill unpublishedPublicSkill; + private Skill hiddenPublicSkill; private static final Long NAMESPACE_ID = 1L; private static final String OWNER_ID = "user-100"; @@ -26,8 +28,15 @@ void setUp() { checker = new VisibilityChecker(); publicSkill = new Skill(NAMESPACE_ID, "public-skill", OWNER_ID, SkillVisibility.PUBLIC); + publicSkill.setLatestVersionId(10L); namespaceOnlySkill = new Skill(NAMESPACE_ID, "namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY); + namespaceOnlySkill.setLatestVersionId(11L); privateSkill = new Skill(NAMESPACE_ID, "private-skill", OWNER_ID, SkillVisibility.PRIVATE); + privateSkill.setLatestVersionId(12L); + unpublishedPublicSkill = new Skill(NAMESPACE_ID, "draft-public-skill", OWNER_ID, SkillVisibility.PUBLIC); + hiddenPublicSkill = new Skill(NAMESPACE_ID, "hidden-public-skill", OWNER_ID, SkillVisibility.PUBLIC); + hiddenPublicSkill.setLatestVersionId(13L); + hiddenPublicSkill.setHidden(true); } @Test @@ -99,4 +108,54 @@ void testPrivateSkillNotAccessibleByNonMember() { boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of()); assertFalse(canAccess); } + + @Test + void testUnpublishedSkillNotAccessibleByAnonymousEvenWhenPublic() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, null, Map.of()); + assertFalse(canAccess); + } + + @Test + void testUnpublishedSkillNotAccessibleByOtherUserEvenWhenPublic() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of()); + assertFalse(canAccess); + } + + @Test + void testUnpublishedSkillNotAccessibleByAdmin() { + Map roles = Map.of(NAMESPACE_ID, NamespaceRole.ADMIN); + boolean canAccess = checker.canAccess(unpublishedPublicSkill, ADMIN_USER_ID, roles); + assertFalse(canAccess); + } + + @Test + void testUnpublishedSkillAccessibleByOwner() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OWNER_ID, Map.of()); + assertTrue(canAccess); + } + + @Test + void testHiddenSkillNotAccessibleByAnonymous() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, null, Map.of()); + assertFalse(canAccess); + } + + @Test + void testHiddenSkillNotAccessibleByOtherUser() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of()); + assertFalse(canAccess); + } + + @Test + void testHiddenSkillAccessibleByOwner() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OWNER_ID, Map.of()); + assertTrue(canAccess); + } + + @Test + void testHiddenSkillAccessibleByNamespaceAdmin() { + Map roles = Map.of(NAMESPACE_ID, NamespaceRole.ADMIN); + boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles); + assertTrue(canAccess); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index 2c864578..15969023 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -124,7 +124,7 @@ void testAllowsMissingVersion() { } @Test - void testThrowsWhenInvalidYaml() { + void testFallsBackToLooseFrontmatterParsingWhenYamlSyntaxIsNotStrict() { String content = """ --- name: test-skill @@ -134,11 +134,29 @@ void testThrowsWhenInvalidYaml() { Body """; - DomainBadRequestException exception = assertThrows( - DomainBadRequestException.class, - () -> parser.parse(content) - ); - assertEquals("error.skill.metadata.yaml.invalid", exception.messageCode()); + SkillMetadata metadata = parser.parse(content); + + assertEquals("test-skill", metadata.name()); + assertEquals("[unclosed bracket", metadata.description()); + assertEquals("1.0.0", metadata.version()); + } + + @Test + void testAllowsColonInDescriptionWithoutStrictYamlQuoting() { + String content = """ + --- + name: clawdbot + description: Send messages from Clawdbot via the discord tool: send messages, react, post or edit + version: 1.0.0 + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("clawdbot", metadata.name()); + assertEquals("Send messages from Clawdbot via the discord tool: send messages, react, post or edit", metadata.description()); + assertEquals("1.0.0", metadata.version()); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 876ea1d6..ba24003b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -4,7 +4,9 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -16,11 +18,14 @@ import org.springframework.context.ApplicationEventPublisher; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.lang.reflect.Field; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -36,6 +41,10 @@ class SkillDownloadServiceTest { @Mock private SkillVersionRepository skillVersionRepository; @Mock + private SkillVersionStatsRepository skillVersionStatsRepository; + @Mock + private SkillFileRepository skillFileRepository; + @Mock private SkillTagRepository skillTagRepository; @Mock private ObjectStorageService objectStorageService; @@ -45,17 +54,22 @@ class SkillDownloadServiceTest { private ApplicationEventPublisher eventPublisher; private SkillDownloadService service; + private SkillSlugResolutionService skillSlugResolutionService; @BeforeEach void setUp() { + skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); service = new SkillDownloadService( namespaceRepository, skillRepository, skillVersionRepository, + skillVersionStatsRepository, + skillFileRepository, skillTagRepository, objectStorageService, visibilityChecker, - eventPublisher + eventPublisher, + skillSlugResolutionService ); } @@ -71,6 +85,7 @@ void testDownloadLatest_Success() throws Exception { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Test Skill"); skill.setStatus(SkillStatus.ACTIVE); skill.setLatestVersionId(10L); @@ -82,20 +97,24 @@ void testDownloadLatest_Success() throws Exception { ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any(), eq("Test Skill-1.0.0.zip"))).thenReturn(null); // Act SkillDownloadService.DownloadResult result = service.downloadLatest(namespaceSlug, skillSlug, userId, userNsRoles); // Assert assertNotNull(result); - assertEquals("test-skill-1.0.0.zip", result.filename()); + assertEquals("Test Skill-1.0.0.zip", result.filename()); assertEquals(1000L, result.contentLength()); + assertNotNull(result.openContent()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -112,6 +131,7 @@ void testDownloadByTag_Success() throws Exception { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Test Skill"); skill.setStatus(SkillStatus.ACTIVE); SkillTag tag = new SkillTag(1L, tagName, 10L, userId); SkillVersion version = new SkillVersion(1L, "1.0.0", userId); @@ -122,20 +142,64 @@ void testDownloadByTag_Success() throws Exception { ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any(), eq("Test Skill-1.0.0.zip"))).thenReturn(null); // Act SkillDownloadService.DownloadResult result = service.downloadByTag(namespaceSlug, skillSlug, tagName, userId, userNsRoles); // Assert assertNotNull(result); - assertEquals("test-skill-1.0.0.zip", result.filename()); + assertEquals("Test Skill-1.0.0.zip", result.filename()); + assertNotNull(result.openContent()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_WithPresignedUrlStillProvidesStreamFallback() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setDisplayName("Generate Commit Message"); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + String storageKey = "packages/1/10/bundle.zip"; + InputStream content = new ByteArrayInputStream("test".getBytes()); + ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + when(objectStorageService.exists(storageKey)).thenReturn(true); + when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); + when(objectStorageService.getObject(storageKey)).thenReturn(content); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any(), eq("Generate Commit Message-1.0.0.zip"))) + .thenReturn("http://minio.local/presigned"); + + SkillDownloadService.DownloadResult result = service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles); + + assertEquals("http://minio.local/presigned", result.presignedUrl()); + assertNotNull(result.openContent()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -157,12 +221,123 @@ void testDownloadVersion_ShouldRejectDraftVersion() throws Exception { version.setStatus(SkillVersionStatus.DRAFT); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); assertThrows(DomainBadRequestException.class, () -> service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setDisplayName("Generate Commit Message"); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile file = new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of(file)); + when(objectStorageService.exists("skills/1/10/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/1/10/SKILL.md")).thenReturn(new ByteArrayInputStream("test".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles); + + assertNull(result.presignedUrl()); + assertTrue(result.fallbackBundle()); + assertEquals("Generate Commit Message-1.0.0.zip", result.filename()); + assertEquals("application/zip", result.contentType()); + assertTrue(result.contentLength() > 0); + + try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { + var entry = zipInputStream.getNextEntry(); + assertNotNull(entry); + assertEquals("SKILL.md", entry.getName()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + zipInputStream.transferTo(output); + assertEquals("test", output.toString()); + } + + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_AllowsAnonymousForGlobalPublicSkill() throws Exception { + Namespace namespace = new Namespace("global", "Global", "system"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + Skill skill = new Skill(1L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setDisplayName("Demo Skill"); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of( + new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"))); + when(objectStorageService.exists("skills/1/10/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/1/10/SKILL.md")).thenReturn(new ByteArrayInputStream("test".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of()); + + assertNotNull(result); + assertEquals("Demo Skill-1.0.0.zip", result.filename()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill skill = new Skill(2L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of())); + + verify(visibilityChecker, never()).canAccess(any(), any(), anyMap()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } private void setId(Object entity, Long id) throws Exception { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java new file mode 100644 index 00000000..572138b6 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -0,0 +1,251 @@ +package com.iflytek.skillhub.domain.skill.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +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.storage.ObjectStorageService; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import java.util.Map; +import java.time.ZoneOffset; +import org.springframework.context.ApplicationEventPublisher; +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; + +@ExtendWith(MockitoExtension.class) +class SkillGovernanceServiceTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T09:00:00Z"), ZoneOffset.UTC); + + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private SkillFileRepository skillFileRepository; + @Mock + private ObjectStorageService objectStorageService; + @Mock + private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; + + private SkillGovernanceService service; + + @BeforeEach + void setUp() { + service = new SkillGovernanceService( + skillRepository, + skillVersionRepository, + skillFileRepository, + objectStorageService, + auditLogService, + eventPublisher, + CLOCK + ); + } + + @Test + void hideSkill_marksSkillHidden() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.hideSkill(10L, "admin", "127.0.0.1", "JUnit", "policy"); + + assertThat(result.isHidden()).isTrue(); + assertThat(result.getHiddenBy()).isEqualTo("admin"); + assertThat(result.getHiddenAt()).isEqualTo(Instant.now(CLOCK)); + verify(auditLogService).record("admin", "HIDE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"policy\"}"); + } + + @Test + void archiveSkill_marksSkillArchived() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.archiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit", "cleanup"); + + assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED); + verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}"); + verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class)); + } + + @Test + void unarchiveSkill_restoresActiveStatus() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setStatus(SkillStatus.ARCHIVED); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.unarchiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit"); + + assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE); + verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null); + verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class)); + } + + @Test + void archiveSkill_requiresOwnerOrNamespaceAdmin() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + + assertThrows(DomainForbiddenException.class, + () -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), "127.0.0.1", "JUnit", null)); + } + + @Test + void yankVersion_setsYankedStatus() { + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version)); + given(skillVersionRepository.save(version)).willReturn(version); + given(skillRepository.findById(2L)).willReturn(Optional.empty()); + + SkillVersion result = service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken"); + + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); + assertThat(result.getYankedBy()).isEqualTo("admin"); + assertThat(result.getYankedAt()).isEqualTo(Instant.now(CLOCK)); + verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); + } + + @Test + void withdrawPendingVersion_demotesVersionToDraft() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + given(skillVersionRepository.save(version)).willReturn(version); + given(skillRepository.save(skill)).willReturn(skill); + + SkillVersion result = service.withdrawPendingVersion(skill, version, "owner"); + + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT); + verify(skillVersionRepository).save(version); + verify(skillRepository).save(skill); + verify(objectStorageService, never()).deleteObject(any()); + } + + @Test + void yankVersion_recomputesLatestPublishedPointer() { + SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner"); + setField(yanked, "id", 22L); + yanked.setStatus(SkillVersionStatus.PUBLISHED); + yanked.setPublishedAt(Instant.parse("2026-03-18T10:00:00Z")); + + SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner"); + setField(fallback, "id", 11L); + fallback.setStatus(SkillVersionStatus.PUBLISHED); + fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z")); + + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + skill.setLatestVersionId(22L); + + given(skillVersionRepository.findById(22L)).willReturn(Optional.of(yanked)); + given(skillVersionRepository.save(yanked)).willReturn(yanked); + given(skillRepository.findById(2L)).willReturn(Optional.of(skill)); + given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(java.util.List.of(fallback)); + given(skillRepository.save(skill)).willReturn(skill); + + service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken"); + + assertThat(skill.getLatestVersionId()).isEqualTo(11L); + verify(skillRepository).save(skill); + } + + @Test + void deleteVersion_removesDraftFilesAndBundle() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.DRAFT); + SkillVersion otherVersion = new SkillVersion(2L, "2.0.0", "owner"); + setField(otherVersion, "id", 3L); + otherVersion.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(version, otherVersion)); + SkillFile readme = new SkillFile(version.getId(), "README.md", 10L, "text/markdown", "sha1", "skills/demo/readme"); + SkillFile icon = new SkillFile(version.getId(), "icon.png", 20L, "image/png", "sha2", "skills/demo/icon"); + given(skillFileRepository.findByVersionId(version.getId())).willReturn(java.util.List.of(readme, icon)); + + service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit"); + + verify(objectStorageService).deleteObjects(argThat(keys -> + keys.size() == 2 + && keys.contains("skills/demo/readme") + && keys.contains("skills/demo/icon"))); + verify(objectStorageService).deleteObject("packages/1/2/bundle.zip"); + verify(skillFileRepository).deleteByVersionId(2L); + verify(skillVersionRepository).delete(version); + verify(auditLogService).record("owner", "DELETE_SKILL_VERSION", "SKILL_VERSION", 2L, null, "127.0.0.1", "JUnit", "{\"version\":\"1.0.0\"}"); + } + + @Test + void deleteVersion_rejectsPublishedVersion() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + assertThrows(DomainBadRequestException.class, + () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit")); + + verify(skillVersionRepository, never()).delete(any()); + verify(objectStorageService, never()).deleteObject(any()); + } + + @Test + void deleteVersion_rejectsLastRemainingVersion() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.DRAFT); + given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, + () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit")); + assertThat(ex.messageCode()).isEqualTo("error.skill.version.delete.lastVersion"); + + verify(skillVersionRepository, never()).delete(any()); + } + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index f0881441..97043f98 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -1,10 +1,13 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.*; @@ -15,17 +18,23 @@ import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.context.ApplicationEventPublisher; 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; -import org.springframework.context.ApplicationEventPublisher; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -34,6 +43,8 @@ @ExtendWith(MockitoExtension.class) class SkillPublishServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T12:00:00Z"), ZoneOffset.UTC); + @Mock private NamespaceRepository namespaceRepository; @Mock @@ -53,9 +64,9 @@ class SkillPublishServiceTest { @Mock private PrePublishValidator prePublishValidator; @Mock - private ApplicationEventPublisher eventPublisher; - @Mock private ReviewTaskRepository reviewTaskRepository; + @Mock + private ApplicationEventPublisher eventPublisher; private SkillPublishService service; private ObjectMapper objectMapper; @@ -73,9 +84,10 @@ void setUp() { skillPackageValidator, skillMetadataParser, prePublishValidator, - eventPublisher, objectMapper, - reviewTaskRepository + reviewTaskRepository, + eventPublisher, + CLOCK ); } @@ -97,25 +109,30 @@ void testPublishFromEntries_Success() throws Exception { Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); - SkillVersion version = new SkillVersion(1L, "1.0.0", publisherId); - setId(version, 10L); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); - when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); - when(skillVersionRepository.save(any())).thenReturn(version); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); // Act SkillPublishService.PublishResult result = service.publishFromEntries( namespaceSlug, entries, publisherId, - SkillVisibility.PUBLIC + SkillVisibility.PUBLIC, + Set.of() ); // Assert @@ -124,17 +141,17 @@ void testPublishFromEntries_Success() throws Exception { assertEquals("test-skill", result.slug()); assertEquals("1.0.0", result.version().getVersion()); assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); - assertNull(skill.getLatestVersionId()); - verify(eventPublisher, never()).publishEvent(any()); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + verify(eventPublisher, never()).publishEvent(any()); } @Test - void testPublishFromEntries_ShouldKeepLatestVersionPointingToPublishedVersion() throws Exception { + void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; - String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.1.0\n---\nBody"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); List entries = List.of(skillMd); @@ -142,41 +159,55 @@ void testPublishFromEntries_ShouldKeepLatestVersionPointingToPublishedVersion() Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); NamespaceMember member = mock(NamespaceMember.class); - SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.1.0", "Body", Map.of()); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); - skill.setLatestVersionId(5L); - SkillVersion version = new SkillVersion(1L, "1.1.0", publisherId); - setId(version, 11L); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); + SkillVersion draftVersion = new SkillVersion(1L, "1.0.0", publisherId); + draftVersion.setStatus(SkillVersionStatus.DRAFT); + setId(draftVersion, 8L); + SkillFile oldFile = new SkillFile(8L, "SKILL.md", (long) skillMdContent.length(), "text/markdown", "abc", "skills/1/8/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.1.0"))).thenReturn(Optional.empty()); - when(skillVersionRepository.save(any())).thenReturn(version); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(draftVersion)); + when(skillFileRepository.findByVersionId(8L)).thenReturn(List.of(oldFile)); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); SkillPublishService.PublishResult result = service.publishFromEntries( namespaceSlug, entries, publisherId, - SkillVisibility.PUBLIC + SkillVisibility.PUBLIC, + Set.of() ); - assertEquals(11L, result.version().getId()); - assertEquals(5L, skill.getLatestVersionId()); - verify(eventPublisher, never()).publishEvent(any()); + assertEquals("1.0.0", result.version().getVersion()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(skillFileRepository).deleteByVersionId(8L); + verify(skillVersionRepository).delete(draftVersion); + verify(objectStorageService).deleteObjects(List.of("skills/1/8/SKILL.md")); + verify(objectStorageService).deleteObject("packages/1/8/bundle.zip"); } @Test - void testPublishFromEntries_ShouldNotOverwritePublishedSkillMetadataBeforeApproval() throws Exception { + void testPublishFromEntries_ShouldSlugifyNameBeforeLookupAndResponse() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; - String skillMdContent = "---\nname: pending-name\ndescription: Pending Summary\nversion: 1.1.0\n---\nBody"; + String skillMdContent = "---\nname: Smoke Skill Two\ndescription: Test\nversion: 0.2.0\n---\nBody"; PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); List entries = List.of(skillMd); @@ -184,78 +215,122 @@ void testPublishFromEntries_ShouldNotOverwritePublishedSkillMetadataBeforeApprov Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); NamespaceMember member = mock(NamespaceMember.class); - SkillMetadata metadata = new SkillMetadata("pending-name", "Pending Summary", "1.1.0", "Body", Map.of()); - - Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); - setId(skill, 1L); - skill.setDisplayName("Published Name"); - skill.setSummary("Published Summary"); - skill.setUpdatedBy("previous-reviewer"); - skill.setLatestVersionId(5L); - - SkillVersion version = new SkillVersion(1L, "1.1.0", publisherId); - setId(version, 11L); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); + SkillMetadata metadata = new SkillMetadata("Smoke Skill Two", "Test", "0.2.0", "Body", Map.of()); + Skill skill = new Skill(1L, "smoke-skill-two", publisherId, SkillVisibility.PUBLIC); + setId(skill, 2L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("pending-name"))).thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.1.0"))).thenReturn(Optional.empty()); - when(skillVersionRepository.save(any())).thenReturn(version); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("smoke-skill-two"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 20L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); - service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC); + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); - assertEquals("Published Name", skill.getDisplayName()); - assertEquals("Published Summary", skill.getSummary()); - assertEquals("previous-reviewer", skill.getUpdatedBy()); - assertEquals(5L, skill.getLatestVersionId()); - verify(skillRepository, never()).save(skill); + assertEquals("smoke-skill-two", result.slug()); + verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two"); + verify(reviewTaskRepository).save(any(ReviewTask.class)); } @Test - void testPublishFromEntries_ShouldSlugifyNameBeforeLookupAndResponse() throws Exception { + void testPublishFromEntries_SuperAdminShouldAutoPublish() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; - String skillMdContent = "---\nname: Smoke Skill Two\ndescription: Test\nversion: 0.2.0\n---\nBody"; + String skillMdContent = "---\nname: Auto Skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); List entries = List.of(skillMd); Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); - NamespaceMember member = mock(NamespaceMember.class); - SkillMetadata metadata = new SkillMetadata("Smoke Skill Two", "Test", "0.2.0", "Body", Map.of()); - - Skill skill = new Skill(1L, "smoke-skill-two", publisherId, SkillVisibility.PUBLIC); - setId(skill, 2L); - SkillVersion version = new SkillVersion(2L, "0.2.0", publisherId); - setId(version, 20L); + SkillMetadata metadata = new SkillMetadata("Auto Skill", "Test", "1.0.0", "Body", Map.of()); + Skill skill = new Skill(1L, "auto-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty()); - when(skillVersionRepository.save(any())).thenReturn(version); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("auto-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); SkillPublishService.PublishResult result = service.publishFromEntries( namespaceSlug, entries, publisherId, - SkillVisibility.PUBLIC + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN") ); - assertEquals("smoke-skill-two", result.slug()); - verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two"); + assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); + assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + verify(skillRepository).save(argThat(savedSkill -> + savedSkill.getLatestVersionId() != null && savedSkill.getLatestVersionId().equals(10L))); + verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); } @Test - void testPublishFromEntries_ShouldRejectMissingVersionBeforePersistence() throws Exception { + void testPublishFromEntries_ShouldRejectArchivedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + Skill archivedSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + archivedSkill.setStatus(SkillStatus.ARCHIVED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(archivedSkill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(archivedSkill)); + + assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + } + + @Test + void testPublishFromEntries_ShouldAutoGenerateVersionWhenMissing() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; String skillMdContent = "---\nname: test-skill\ndescription: Test\n---\nBody"; @@ -272,10 +347,22 @@ void testPublishFromEntries_ShouldRejectMissingVersionBeforePersistence() throws when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC))); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC))); + when(skillVersionRepository.findBySkillIdAndVersion(any(), anyString())).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); - assertThrows(DomainBadRequestException.class, () -> - service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC)); - verify(skillVersionRepository, never()).save(any()); + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); + + assertEquals("20260318.120000", result.version().getVersion()); } @Test @@ -286,10 +373,29 @@ void testPublishFromEntries_NamespaceNotFound() { // Act & Assert assertThrows(DomainBadRequestException.class, () -> - service.publishFromEntries(namespaceSlug, List.of(), "user-100", SkillVisibility.PUBLIC) + service.publishFromEntries(namespaceSlug, List.of(), "user-100", SkillVisibility.PUBLIC, Set.of()) ); } + @Test + void testPublishFromEntries_ShouldRejectFrozenNamespace() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + setId(namespace, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, () -> + service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of())); + } + @Test void testPublishFromEntries_NotAMember() throws Exception { // Arrange @@ -303,8 +409,327 @@ void testPublishFromEntries_NotAMember() throws Exception { // Act & Assert assertThrows(DomainBadRequestException.class, () -> - service.publishFromEntries(namespaceSlug, List.of(), publisherId, SkillVisibility.PUBLIC) + service.publishFromEntries(namespaceSlug, List.of(), publisherId, SkillVisibility.PUBLIC, Set.of()) + ); + } + + @Test + void testPublishFromEntries_SuperAdminShouldBypassNamespaceMembership() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: Admin Skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + SkillMetadata metadata = new SkillMetadata("Admin Skill", "Test", "1.0.0", "Body", Map.of()); + Skill skill = new Skill(1L, "admin-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("admin-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN") ); + + assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); + verify(namespaceMemberRepository, never()).findByNamespaceIdAndUserId(any(), any()); + } + + @Test + void testPublishFromEntries_AllowsDescriptionLongerThanPreviousDatabaseLimit() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String longDescription = "x".repeat(513); + String skillMdContent = "---\nname: Too Long Skill\ndescription: ignored\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("Too Long Skill", longDescription, "1.0.0", "Body", Map.of()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + + Skill skill = new Skill(namespace.getId(), "too-long-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 10L); + when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), "too-long-skill", publisherId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.0.0")).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any())).thenAnswer(invocation -> { + SkillVersion version = invocation.getArgument(0); + if (version.getId() == null) { + setId(version, 20L); + } + return version; + }); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); + + assertEquals(longDescription, skill.getSummary()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(prePublishValidator).validate(any()); + verify(skillRepository).save(skill); + } + + @Test + void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = """ + --- + name: Demo Skill + description: Original summary + version: 1.2.3 + --- + Hello world + """; + byte[] readmeBytes = "# Demo".getBytes(StandardCharsets.UTF_8); + + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + SkillFile readmeFile = new SkillFile(sourceVersion.getId(), "README.md", (long) readmeBytes.length, "text/markdown", "hash2", "skills/11/21/README.md"); + + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", + "Original summary", + "1.2.4", + "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile, readmeFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(objectStorageService.getObject(readmeFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(readmeBytes)); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 30L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.rereleasePublishedVersion( + skill.getId(), + "1.2.3", + "1.2.4", + publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + ); + + assertEquals("1.2.4", result.version().getVersion()); + assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); + assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); + assertEquals(30L, skill.getLatestVersionId()); + verify(reviewTaskRepository, never()).save(any()); + verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + verify(skillPackageValidator).validate(argThat(entries -> + entries.size() == 2 + && entries.stream().anyMatch(entry -> + entry.path().equals("SKILL.md") + && new String(entry.content(), StandardCharsets.UTF_8).contains("version: 1.2.4")))); + verify(prePublishValidator).validate(any()); + } + + @Test + void testRereleasePublishedVersion_ShouldRejectDuplicateTargetVersion() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion existingTarget = new SkillVersion(skill.getId(), "1.2.4", publisherId); + setId(existingTarget, 22L); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.of(existingTarget)); + + assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion( + skill.getId(), + "1.2.3", + "1.2.4", + publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + )); + } + + @Test + void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + // Existing skill owned by another user with a published version + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + + assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of() + )); + } + + @Test + void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + // Existing skill owned by another user with NO published version + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC); + setId(existingSkill, 1L); + + Skill newSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(newSkill, 2L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.empty()); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 10L); + return saved; + }); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of() + ); + + assertNotNull(result); + assertEquals("test-skill", result.slug()); + } + + @Test + void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 2.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of()); + + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + // Existing pending version + SkillVersion pendingV1 = new SkillVersion(1L, "1.0.0", publisherId); + pendingV1.setStatus(SkillVersionStatus.PENDING_REVIEW); + setId(pendingV1, 5L); + ReviewTask pendingTask = new ReviewTask(5L, 1L, publisherId); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of(pendingV1)); + when(reviewTaskRepository.findBySkillVersionIdAndStatus(5L, com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(pendingTask)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("2.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 10L); + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); + + // Verify pending version was withdrawn to DRAFT + assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + verify(reviewTaskRepository).delete(pendingTask); + verify(skillVersionRepository).save(pendingV1); } private void setId(Object entity, Long id) throws Exception { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 91b2cac9..65963a8b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -3,6 +3,9 @@ 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.PromotionRequestRepository; +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.*; @@ -18,6 +21,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.io.UncheckedIOException; import java.lang.reflect.Field; import java.util.List; import java.util.Map; @@ -44,11 +48,17 @@ class SkillQueryServiceTest { private ObjectStorageService objectStorageService; @Mock private VisibilityChecker visibilityChecker; + @Mock + private PromotionRequestRepository promotionRequestRepository; private SkillQueryService service; + private SkillSlugResolutionService skillSlugResolutionService; + private SkillLifecycleProjectionService skillLifecycleProjectionService; @BeforeEach void setUp() { + skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); + skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository); service = new SkillQueryService( namespaceRepository, skillRepository, @@ -56,7 +66,10 @@ void setUp() { skillFileRepository, skillTagRepository, objectStorageService, - visibilityChecker + visibilityChecker, + promotionRequestRepository, + skillSlugResolutionService, + skillLifecycleProjectionService ); } @@ -78,9 +91,10 @@ void testGetSkillDetail_Success() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); @@ -91,7 +105,47 @@ void testGetSkillDetail_Success() throws Exception { assertNotNull(result); assertEquals(skillSlug, result.slug()); assertEquals("Test Skill", result.displayName()); - assertEquals("1.0.0", result.latestVersion()); + assertNotNull(result.headlineVersion()); + assertEquals("1.0.0", result.headlineVersion().version()); + assertFalse(result.canReport()); + } + + @Test + void testGetSkillDetail_PrefersCurrentUsersOwnSkillOverOtherPublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + + Skill publishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(publishedSkill, 1L); + publishedSkill.setDisplayName("Published Skill"); + publishedSkill.setLatestVersionId(11L); + + Skill ownSkill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(ownSkill, 2L); + ownSkill.setDisplayName("Own Skill"); + ownSkill.setLatestVersionId(22L); + + SkillVersion ownVersion = new SkillVersion(2L, "2.0.0", userId); + setId(ownVersion, 22L); + ownVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill)); + when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertEquals(2L, result.id()); + assertEquals("Own Skill", result.displayName()); + assertNotNull(result.headlineVersion()); + assertEquals("2.0.0", result.headlineVersion().version()); + assertFalse(result.canReport()); } @Test @@ -106,9 +160,10 @@ void testGetSkillDetail_AccessDenied() throws Exception { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PRIVATE); setId(skill, 1L); + skill.setLatestVersionId(11L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); // Act & Assert @@ -117,6 +172,25 @@ void testGetSkillDetail_AccessDenied() throws Exception { ); } + @Test + void testGetSkillDetail_ShouldHideArchivedNamespaceFromAnonymousUsers() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "test-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "user-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); + } + @Test void testListSkillsByNamespace() throws Exception { // Arrange @@ -145,6 +219,80 @@ void testListSkillsByNamespace() throws Exception { assertEquals("skill1", result.getContent().get(0).getSlug()); } + @Test + void testGetSkillDetail_ShouldHideOtherUsersUnpublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String viewerId = "user-300"; + Map userNsRoles = Map.of(1L, NamespaceRole.ADMIN); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill unpublishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, viewerId, userNsRoles)); + } + + @Test + void testListSkillsByNamespace_ShouldHideOtherUsersUnpublishedSkills() throws Exception { + String namespaceSlug = "test-ns"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + Pageable pageable = PageRequest.of(0, 10); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill ownUnpublishedSkill = new Skill(1L, "own-skill", userId, SkillVisibility.PUBLIC); + setId(ownUnpublishedSkill, 1L); + Skill othersUnpublishedSkill = new Skill(1L, "other-skill", "user-200", SkillVisibility.PUBLIC); + setId(othersUnpublishedSkill, 2L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) + .thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill)); + when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true); + when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false); + + Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); + + assertEquals(1, result.getTotalElements()); + assertEquals("own-skill", result.getContent().get(0).getSlug()); + } + + @Test + void testListSkillsByNamespace_ShouldHideHiddenSkillsFromRegularUsers() throws Exception { + String namespaceSlug = "test-ns"; + String userId = "user-100"; + Map userNsRoles = Map.of(); + Pageable pageable = PageRequest.of(0, 10); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill visibleSkill = new Skill(1L, "visible-skill", "user-200", SkillVisibility.PUBLIC); + setId(visibleSkill, 1L); + visibleSkill.setLatestVersionId(11L); + Skill hiddenSkill = new Skill(1L, "hidden-skill", "user-300", SkillVisibility.PUBLIC); + setId(hiddenSkill, 2L); + hiddenSkill.setLatestVersionId(12L); + hiddenSkill.setHidden(true); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) + .thenReturn(List.of(visibleSkill, hiddenSkill)); + when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true); + when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false); + + Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); + + assertEquals(1, result.getTotalElements()); + assertEquals("visible-skill", result.getContent().get(0).getSlug()); + } + @Test void testListFiles() throws Exception { // Arrange @@ -164,10 +312,11 @@ void testListFiles() throws Exception { Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); + when(objectStorageService.exists("key1")).thenReturn(true); // Act List result = service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles); @@ -194,7 +343,7 @@ void testListFiles_ShouldRejectDraftVersion() throws Exception { skillVersion.setStatus(SkillVersionStatus.DRAFT); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); @@ -202,6 +351,103 @@ void testListFiles_ShouldRejectDraftVersion() throws Exception { service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles)); } + @Test + void testGetFileContent_ShouldTranslateMissingStorageObject() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + String filePath = "SKILL.md"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); + when(objectStorageService.exists(file.getStorageKey())).thenReturn(true); + when(objectStorageService.getObject(file.getStorageKey())) + .thenThrow(new UncheckedIOException(new java.io.FileNotFoundException(file.getStorageKey()))); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.getFileContent(namespaceSlug, skillSlug, version, filePath, "user-100", userNsRoles)); + + assertEquals("error.skill.file.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{filePath}, ex.messageArgs()); + } + + @Test + void testListFiles_ShouldHideEntriesWhoseStorageObjectIsMissing() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile availableFile = new SkillFile(1L, "SKILL.md", 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md"); + SkillFile missingFile = new SkillFile(1L, "_meta.json", 100L, "application/json", "hash2", "skills/1/1/_meta.json"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile)); + when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true); + when(objectStorageService.exists("skills/1/1/_meta.json")).thenReturn(false); + + List result = service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles); + + assertEquals(1, result.size()); + assertEquals("SKILL.md", result.get(0).getFilePath()); + } + + @Test + void testIsDownloadAvailable_ShouldReturnFalseWhenBundleIsMissing() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + assertFalse(service.isDownloadAvailable(version)); + } + + @Test + void testIsDownloadAvailable_ShouldReturnTrueWhenPublishedVersionHasFiles() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + + assertTrue(service.isDownloadAvailable(version)); + } + + @Test + void testIsDownloadAvailable_ShouldNotHitObjectStorageForListSignals() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + + assertTrue(service.isDownloadAvailable(version)); + verifyNoInteractions(objectStorageService, skillFileRepository); + } + @Test void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception { String namespaceSlug = "test-ns"; @@ -221,7 +467,7 @@ void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception { skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); @@ -255,10 +501,11 @@ void testListFilesByTag_ShouldResolveLatestTag() throws Exception { SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + when(objectStorageService.exists("storage-key")).thenReturn(true); List result = service.listFilesByTag(namespaceSlug, skillSlug, "latest", "user-100", userNsRoles); @@ -266,6 +513,43 @@ void testListFilesByTag_ShouldResolveLatestTag() throws Exception { assertEquals("README.md", result.get(0).getFilePath()); } + @Test + void testListVersions_ShouldIncludePendingAndRejectedForLifecycleManagers() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.OWNER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 10L); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(java.time.Instant.parse("2026-03-01T10:00:00Z")); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + SkillVersion rejected = new SkillVersion(1L, "1.2.0", ownerId); + setId(rejected, 12L); + rejected.setStatus(SkillVersionStatus.REJECTED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected)); + + Page result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20)); + + assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + } + @Test void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception { String namespaceSlug = "test-ns"; @@ -290,7 +574,7 @@ void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exceptio SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of(version100, version110)); @@ -332,7 +616,7 @@ void testResolveVersion_ShouldEncodeDownloadUrlPathSegments() throws Exception { SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); @@ -351,6 +635,397 @@ void testResolveVersion_ShouldEncodeDownloadUrlPathSegments() throws Exception { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertTrue(result.canManageLifecycle()); + } + + @Test + void testGetSkillDetail_ShouldAllowPromotionForTeamOwnerOnPublishedSkill() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty()); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertNotNull(result.publishedVersion()); + assertEquals(11L, result.publishedVersion().id()); + assertTrue(result.canSubmitPromotion()); + } + + @Test + void testGetSkillDetail_ShouldHidePromotionWhenPendingPromotionExists() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertFalse(result.canSubmitPromotion()); + } + + @Test + void testGetSkillDetail_ShouldHidePromotionWhenSkillAlreadyPromoted() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)) + .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertFalse(result.canSubmitPromotion()); + } + + @Test + void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertFalse(result.canManageLifecycle()); + assertFalse(result.canSubmitPromotion()); + } + + @Test + void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(java.time.Instant.parse("2026-03-01T10:00:00Z")); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 12L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of(pending)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertNotNull(result.headlineVersion()); + assertEquals("1.1.0", result.headlineVersion().version()); + assertEquals("PENDING_REVIEW", result.headlineVersion().status()); + assertEquals("OWNER_PREVIEW", result.resolutionMode()); + assertFalse(result.canInteract()); + } + + @Test + void testGetSkillDetail_ShouldKeepPublishedVersionWhenSkillAlreadyPublic() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 12L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertNotNull(result.headlineVersion()); + assertEquals("1.0.0", result.headlineVersion().version()); + assertEquals("PUBLISHED", result.headlineVersion().status()); + assertEquals("PUBLISHED", result.resolutionMode()); + assertTrue(result.canInteract()); + } + + @Test + void testGetVersionDetail_ShouldAllowPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion pending = new SkillVersion(1L, version, ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + pending.setParsedMetadataJson("{\"name\":\"test-skill\"}"); + pending.setManifestJson("[{\"path\":\"SKILL.md\"}]"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + + SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( + namespaceSlug, + skillSlug, + version, + ownerId, + userNsRoles + ); + + assertEquals("PENDING_REVIEW", result.status()); + assertEquals("{\"name\":\"test-skill\"}", result.parsedMetadataJson()); + } + + @Test + void testListFiles_ShouldAllowPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion pending = new SkillVersion(1L, version, ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + when(objectStorageService.exists("storage-key")).thenReturn(true); + + List result = service.listFiles(namespaceSlug, skillSlug, version, ownerId, userNsRoles); + + assertEquals(1, result.size()); + assertEquals("README.md", result.get(0).getFilePath()); + } + + @Test + void testGetVersionDetail_ShouldRejectPendingVersionForNonOwner() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String viewerId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion pending = new SkillVersion(1L, version, "owner-1"); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + skill.setLatestVersionId(10L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + + assertThrows(DomainBadRequestException.class, () -> + service.getVersionDetail(namespaceSlug, skillSlug, version, viewerId, userNsRoles)); + } + + @Test + void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion draft = new SkillVersion(1L, "1.1.0", userId); + setId(draft, 12L); + draft.setStatus(SkillVersionStatus.DRAFT); + SkillVersion rejected = new SkillVersion(1L, "1.2.0", userId); + setId(rejected, 13L); + rejected.setStatus(SkillVersionStatus.REJECTED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + userId, + userNsRoles, + PageRequest.of(0, 10) + ); + + assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + } + + @Test + void testListVersions_ShouldOnlyReturnPublishedForRegularViewers() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + userId, + userNsRoles, + PageRequest.of(0, 10) + ); + + assertEquals(List.of("1.0.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java new file mode 100644 index 00000000..a9233c89 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SkillSlugResolutionServiceTest { + + private final SkillRepository skillRepository = mock(SkillRepository.class); + private final SkillSlugResolutionService service = new SkillSlugResolutionService(skillRepository); + + @Test + void prefersCurrentUsersOwnSkillWhenRequested() throws Exception { + Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L); + Skill ownSkill = createSkill(2L, "demo", "user-1", 22L); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(publishedSkill, ownSkill)); + + Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER); + + assertEquals(2L, resolved.getId()); + } + + @Test + void prefersPublishedSkillForPublicInteractions() throws Exception { + Skill ownDraft = createSkill(2L, "demo", "user-1", null); + Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(ownDraft, publishedSkill)); + + Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.PUBLISHED); + + assertEquals(1L, resolved.getId()); + } + + @Test + void throwsWhenNoSkillMatchesSlug() { + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of()); + + assertThrows(DomainBadRequestException.class, () -> + service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER)); + } + + @Test + void throwsWhenOnlyUnpublishedSkillsBelongToOtherUsers() throws Exception { + Skill otherUsersDraft = createSkill(3L, "demo", "user-2", null); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(otherUsersDraft)); + + assertThrows(DomainBadRequestException.class, () -> + service.resolve(1L, "demo", null, SkillSlugResolutionService.Preference.CURRENT_USER)); + } + + @Test + void throwsWhenOnlyPublishedSkillIsHiddenFromCurrentUser() throws Exception { + Skill hiddenPublishedSkill = createSkill(4L, "demo", "user-2", 44L); + hiddenPublishedSkill.setHidden(true); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(hiddenPublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.resolve(1L, "demo", "user-9", SkillSlugResolutionService.Preference.CURRENT_USER)); + } + + private Skill createSkill(Long id, String slug, String ownerId, Long latestVersionId) throws Exception { + Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC); + Field idField = Skill.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(skill, id); + skill.setLatestVersionId(latestVersionId); + return skill; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 85b5f317..a50596d0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -17,6 +17,7 @@ import java.lang.reflect.Field; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -35,17 +36,23 @@ class SkillTagServiceTest { private SkillVersionRepository skillVersionRepository; @Mock private SkillTagRepository skillTagRepository; + @Mock + private VisibilityChecker visibilityChecker; private SkillTagService service; + private SkillSlugResolutionService skillSlugResolutionService; @BeforeEach void setUp() { + skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); service = new SkillTagService( namespaceRepository, namespaceMemberRepository, skillRepository, skillVersionRepository, - skillTagRepository + skillTagRepository, + visibilityChecker, + skillSlugResolutionService ); } @@ -70,7 +77,7 @@ void testCreateTag_Success() throws Exception { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER))); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version)); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty()); when(skillTagRepository.save(any())).thenReturn(tag); @@ -115,7 +122,7 @@ void testDeleteTag_Success() throws Exception { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN))); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); // Act @@ -168,18 +175,23 @@ void testListTags() throws Exception { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setLatestVersionId(3L); SkillTag tag1 = new SkillTag(1L, "stable", 1L, "user-100"); SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); + when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true); // Act - List result = service.listTags(namespaceSlug, skillSlug); + List result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); // Assert - assertEquals(2, result.size()); + assertEquals(3, result.size()); + assertEquals( + List.of("beta", "latest", "stable"), + result.stream().map(SkillTag::getTagName).sorted().collect(Collectors.toList())); } private void setId(Object entity, Long id) throws Exception { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java new file mode 100644 index 00000000..a40eb5c3 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.domain.skill.validation; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BasicPrePublishValidatorTest { + + private final BasicPrePublishValidator validator = new BasicPrePublishValidator(); + + @Test + void shouldRejectObviousCredentialLeakWithHelpfulLocation() { + PackageEntry skillMd = new PackageEntry( + "SKILL.md", + """ + --- + name: Secret Skill + version: 1.0.0 + --- + token=sk-abcdefghijklmnopqrstuvwxyz123456 + """.getBytes(StandardCharsets.UTF_8), + 91, + "text/markdown" + ); + + ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext( + List.of(skillMd), + new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()), + "user-1", + 1L + )); + + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(error -> + error.contains("SKILL.md") + && error.contains("line 5") + && error.contains("looks like a"))); + } + + @Test + void shouldAllowOrdinaryTextFiles() { + PackageEntry skillMd = new PackageEntry( + "SKILL.md", + """ + --- + name: Safe Skill + version: 1.0.0 + --- + """.getBytes(StandardCharsets.UTF_8), + 45, + "text/markdown" + ); + PackageEntry readme = new PackageEntry( + "README.md", + "This skill documents safe usage.".getBytes(StandardCharsets.UTF_8), + 31, + "text/markdown" + ); + + ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext( + List.of(skillMd, readme), + new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()), + "user-1", + 1L + )); + + assertTrue(result.passed()); + } + + @Test + void shouldIgnoreObviousPlaceholderSecrets() { + PackageEntry skillMd = new PackageEntry( + "SKILL.md", + """ + --- + name: Example Skill + version: 1.0.0 + --- + token=YOUR_TOKEN_HERE + api_key=example-key-value + """.getBytes(StandardCharsets.UTF_8), + 102, + "text/markdown" + ); + + ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext( + List.of(skillMd), + new SkillMetadata("Example Skill", "desc", "1.0.0", "body", Map.of()), + "user-1", + 1L + )); + + assertTrue(result.passed()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index c36f4f42..44c57254 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -76,26 +76,18 @@ void testDisallowedExtension() { @Test void testFileTooLarge() { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - Body - """; - - byte[] largeContent = new byte[2 * 1024 * 1024]; // 2MB - + // Use a custom validator with 1KB single file limit to test the logic + SkillPackageValidator smallValidator = new SkillPackageValidator( + new SkillMetadataParser(), 100, 1024, 100 * 1024 * 1024, + SkillPackagePolicy.ALLOWED_EXTENSIONS); + byte[] bigContent = new byte[1025]; // >1KB List entries = List.of( - new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), - new PackageEntry("large.txt", largeContent, largeContent.length, "text/plain") + skillMdEntry(), + new PackageEntry("big.txt", bigContent, bigContent.length, "text/plain") ); - - ValidationResult result = validator.validate(entries); - + ValidationResult result = smallValidator.validate(entries); assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("File too large") && e.contains("large.txt"))); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("File too large"))); } @Test @@ -143,36 +135,38 @@ void testMissingFrontmatterName() { } @Test - void testPackageTooLarge() { + void testInvalidYamlFrontmatterWithColonInValueShouldStillPass() { String skillMdContent = """ --- - name: test-skill - description: A test skill + name: clawdbot + description: Send messages from Clawdbot via the discord tool: send messages, react, post or edit version: 1.0.0 --- Body """; - byte[] largeContent = new byte[900 * 1024]; // 900KB each - List entries = List.of( - new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), - new PackageEntry("file1.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file2.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file3.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file4.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file5.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file6.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file7.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file8.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file9.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file10.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file11.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file12.txt", largeContent, largeContent.length, "text/plain") + new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown") ); ValidationResult result = validator.validate(entries); + assertTrue(result.passed()); + assertTrue(result.errors().isEmpty()); + } + + @Test + void testPackageTooLarge() { + // Use a custom validator with 2KB total limit to test the logic + SkillPackageValidator smallValidator = new SkillPackageValidator( + new SkillMetadataParser(), 100, 10 * 1024 * 1024, 2048, + SkillPackagePolicy.ALLOWED_EXTENSIONS); + byte[] content = new byte[2000]; // 2KB + List entries = List.of( + skillMdEntry(), // ~50 bytes + new PackageEntry("data.txt", content, content.length, "text/plain") + ); + ValidationResult result = smallValidator.validate(entries); assertFalse(result.passed()); assertTrue(result.errors().stream().anyMatch(e -> e.contains("Package too large"))); } @@ -221,4 +215,86 @@ void testDuplicateNormalizedPathRejected() { assertFalse(result.passed()); assertTrue(result.errors().stream().anyMatch(e -> e.contains("Duplicate package entry path: docs/guide.md"))); } + + @Test + void testSpoofedBinaryTextFileRejected() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + Body + """; + + byte[] binaryPayload = new byte[] {0x4d, 0x5a, 0x00, 0x02}; + + List entries = List.of( + new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("notes.md", binaryPayload, binaryPayload.length, "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); + } + + @Test + void testInvalidSvgPayloadRejected() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + Body + """; + + List entries = List.of( + new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("icon.svg", "not actually svg".getBytes(), 16, "image/svg+xml") + ); + + ValidationResult result = validator.validate(entries); + + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); + } + + @Test + void rejectsJpegWithWrongMagicBytes() { + List entries = List.of( + skillMdEntry(), + new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg") + ); + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg"))); + } + + @Test + void acceptsValidGif() { + byte[] gifHeader = "GIF89a".getBytes(); + byte[] content = new byte[20]; + System.arraycopy(gifHeader, 0, content, 0, gifHeader.length); + List entries = List.of( + skillMdEntry(), + new PackageEntry("anim.gif", content, content.length, "image/gif") + ); + ValidationResult result = validator.validate(entries); + assertTrue(result.passed()); + } + + private PackageEntry skillMdEntry() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + Body + """; + return new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 29ebd961..00bf2133 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +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.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,11 +20,17 @@ @ExtendWith(MockitoExtension.class) class SkillRatingServiceTest { @Mock SkillRatingRepository ratingRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillRatingService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void rate_creates_new_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ void rate_creates_new_rating() { @Test void rate_updates_existing_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 3); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -45,6 +56,7 @@ void rate_updates_existing_rating() { @Test void rate_invalid_score_throws() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); assertThatThrownBy(() -> service.rate(1L, "10", (short) 0)) .isInstanceOf(DomainBadRequestException.class); assertThatThrownBy(() -> service.rate(1L, "10", (short) 6)) @@ -53,8 +65,16 @@ void rate_invalid_score_throws() { @Test void getUserRating_returns_score() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 4); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); assertThat(service.getUserRating(1L, "10")).hasValue((short) 4); } + + @Test + void getUserRating_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getUserRating(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java index a9e30d1b..2c4a2197 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.domain.social; +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.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.junit.jupiter.api.Test; @@ -16,11 +20,17 @@ @ExtendWith(MockitoExtension.class) class SkillStarServiceTest { @Mock SkillStarRepository starRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillStarService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void star_skill_creates_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ void star_skill_creates_record_and_publishes_event() { @Test void star_skill_already_starred_is_idempotent() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); @@ -43,6 +54,7 @@ void star_skill_already_starred_is_idempotent() { @Test void unstar_skill_deletes_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillStar existing = new SkillStar(1L, "10"); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); @@ -54,6 +66,7 @@ void unstar_skill_deletes_record_and_publishes_event() { @Test void unstar_skill_not_starred_is_noop() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); service.unstar(1L, "10"); @@ -64,8 +77,16 @@ void unstar_skill_not_starred_is_noop() { @Test void isStarred_returns_true_when_exists() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); assertThat(service.isStarred(1L, "10")).isTrue(); } + + @Test + void star_skill_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.star(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-domain/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/server/skillhub-domain/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..fdbd0b15 --- /dev/null +++ b/server/skillhub-domain/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml index e3d346ef..07516900 100644 --- a/server/skillhub-infra/pom.xml +++ b/server/skillhub-infra/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 skillhub-infra diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java new file mode 100644 index 00000000..8d6b4606 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.audit.AuditLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuditLogJpaRepository extends JpaRepository, JpaSpecificationExecutor, AuditLogRepository { + + @Override + default Page search(String actorUserId, String action, Pageable pageable) { + Specification specification = Specification.where(null); + if (actorUserId != null && !actorUserId.isBlank()) { + specification = specification.and((root, query, cb) -> cb.equal(root.get("actorUserId"), actorUserId)); + } + if (action != null && !action.isBlank()) { + specification = specification.and((root, query, cb) -> cb.equal(root.get("action"), action)); + } + return findAll(specification, pageable); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java new file mode 100644 index 00000000..6e9daecf --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -0,0 +1,91 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@Primary +public class JpaSkillRepositoryAdapter implements SkillRepository { + + private final SkillJpaRepository delegate; + private final JpaRepository jpaDelegate; + + public JpaSkillRepositoryAdapter(SkillJpaRepository delegate) { + this.delegate = delegate; + this.jpaDelegate = delegate; + } + + @Override + public Optional findById(Long id) { + return jpaDelegate.findById(id); + } + + @Override + public List findByIdIn(List ids) { + return delegate.findByIdIn(ids); + } + + @Override + public List findAll() { + return jpaDelegate.findAll(); + } + + @Override + public List findByNamespaceIdAndSlug(Long namespaceId, String slug) { + return delegate.findByNamespaceIdAndSlug(namespaceId, slug); + } + + @Override + public Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId) { + return delegate.findByNamespaceIdAndSlugAndOwnerId(namespaceId, slug, ownerId); + } + + @Override + public List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { + return delegate.findByNamespaceIdAndStatus(namespaceId, status); + } + + @Override + public Skill save(Skill skill) { + return jpaDelegate.save(skill); + } + + @Override + public void delete(Skill skill) { + jpaDelegate.delete(skill); + } + + @Override + public List findByOwnerId(String ownerId) { + return delegate.findByOwnerId(ownerId); + } + + @Override + public Page findByOwnerId(String ownerId, Pageable pageable) { + return delegate.findByOwnerId(ownerId, pageable); + } + + @Override + public void incrementDownloadCount(Long skillId) { + delegate.incrementDownloadCount(skillId); + } + + @Override + public List findBySlug(String slug) { + return delegate.findBySlug(slug); + } + + @Override + public Optional findByNamespaceSlugAndSlug(String namespaceSlug, String slug) { + return delegate.findByNamespaceSlugAndSlug(namespaceSlug, slug); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index a7b91107..88cf99ea 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -18,9 +18,11 @@ public interface PromotionRequestJpaRepository extends JpaRepository findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); + Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" UPDATE PromotionRequest p SET p.status = :status, 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 570dd992..5ff37a7e 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 @@ -17,6 +17,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index feeda77a..d784235a 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -10,6 +10,7 @@ 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; import java.util.List; import java.util.Optional; @@ -17,7 +18,8 @@ @Repository public interface SkillJpaRepository extends JpaRepository, SkillRepository { List findByIdIn(List ids); - Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByNamespaceIdAndSlug(Long namespaceId, String slug); + Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); @Override default List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { @@ -27,8 +29,20 @@ default List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus sta List findByNamespaceIdAndStatusOrderByCreatedAtDesc(Long namespaceId, SkillStatus status); Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); List findByOwnerId(String ownerId); + Page findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable); + + @Override + default Page findByOwnerId(String ownerId, Pageable pageable) { + return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable); + } @Modifying + @Transactional @Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId") void incrementDownloadCount(@Param("skillId") Long skillId); + + List findBySlug(String slug); + + @Query("SELECT s FROM Skill s JOIN Namespace n ON s.namespaceId = n.id WHERE n.slug = :namespaceSlug AND s.slug = :slug") + Optional findByNamespaceSlugAndSlug(@Param("namespaceSlug") String namespaceSlug, @Param("slug") String slug); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java new file mode 100644 index 00000000..1e352760 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SkillReportJpaRepository extends JpaRepository, SkillReportRepository { + boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); + Page findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable); + List findBySkillIdIn(Collection skillIds); + + @Override + default Page findByStatus(SkillReportStatus status, Pageable pageable) { + return findByStatusOrderByCreatedAtDesc(status, pageable); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java index 4eeae924..85f58db8 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java @@ -35,6 +35,9 @@ public class SkillSearchDocumentEntity { @Column(name = "search_text", columnDefinition = "TEXT") private String searchText; + @Column(name = "semantic_vector", columnDefinition = "TEXT") + private String semanticVector; + @Column(nullable = false, length = 20) private String visibility; @@ -56,6 +59,7 @@ public SkillSearchDocumentEntity( String summary, String keywords, String searchText, + String semanticVector, String visibility, String status) { this.skillId = skillId; @@ -66,6 +70,7 @@ public SkillSearchDocumentEntity( this.summary = summary; this.keywords = keywords; this.searchText = searchText; + this.semanticVector = semanticVector; this.visibility = visibility; this.status = status; } @@ -117,6 +122,10 @@ public String getVisibility() { return visibility; } + public String getSemanticVector() { + return semanticVector; + } + public String getStatus() { return status; } @@ -154,6 +163,10 @@ public void setSearchText(String searchText) { this.searchText = searchText; } + public void setSemanticVector(String semanticVector) { + this.semanticVector = semanticVector; + } + public void setVisibility(String visibility) { this.visibility = visibility; } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java index 7dd6fd62..f9e65ec5 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java @@ -3,10 +3,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; @Repository public interface SkillSearchDocumentJpaRepository extends JpaRepository { Optional findBySkillId(Long skillId); + List findBySkillIdIn(Collection skillIds); void deleteBySkillId(Long skillId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index 319ec036..e6095270 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -14,6 +14,8 @@ @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { List findByIdIn(List ids); + List findBySkillId(Long skillId); + List findBySkillIdIn(List skillIds); Optional findBySkillIdAndVersion(Long skillId, String version); @Override diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java new file mode 100644 index 00000000..3a926142 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillVersionStats; +import com.iflytek.skillhub.domain.skill.SkillVersionStatsRepository; +import java.util.Optional; +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; + +@Repository +public interface SkillVersionStatsJpaRepository extends JpaRepository, SkillVersionStatsRepository { + + @Override + default Optional findBySkillVersionId(Long skillVersionId) { + return findById(skillVersionId); + } + + @Override + @Modifying + @Transactional + @Query( + value = """ + INSERT INTO skill_version_stats (skill_version_id, skill_id, download_count, updated_at) + VALUES (:skillVersionId, :skillId, 1, CURRENT_TIMESTAMP) + ON CONFLICT (skill_version_id) + DO UPDATE SET download_count = skill_version_stats.download_count + 1, + updated_at = CURRENT_TIMESTAMP + """, + nativeQuery = true + ) + void incrementDownloadCount(@Param("skillVersionId") Long skillVersionId, @Param("skillId") Long skillId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index c7dcc3ca..8fbb45cc 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -2,11 +2,32 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserAccountJpaRepository extends JpaRepository, JpaSpecificationExecutor, UserAccountRepository { + + @Override + @Query(""" + SELECT u + FROM UserAccount u + WHERE (:status IS NULL OR u.status = :status) + AND ( + :keyword IS NULL + OR lower(u.displayName) LIKE lower(concat('%', :keyword, '%')) + OR lower(coalesce(u.email, '')) LIKE lower(concat('%', :keyword, '%')) + OR lower(u.id) LIKE lower(concat('%', :keyword, '%')) + ) + """) + Page search(@Param("keyword") String keyword, + @Param("status") UserStatus status, + Pageable pageable); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java new file mode 100644 index 00000000..a4e461f6 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.domain.governance.UserNotificationRepository; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserNotificationJpaRepository extends JpaRepository, UserNotificationRepository { + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 83643cd5..222d2980 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 skillhub-search @@ -27,5 +27,10 @@ org.springframework spring-context + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java new file mode 100644 index 00000000..b92efe24 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.search; + +import java.util.Arrays; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +@Service +public class HashingSearchEmbeddingService implements SearchEmbeddingService { + private static final Pattern TOKEN_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+"); + private static final int DIMENSIONS = 64; + private static final double NGRAM_WEIGHT = 0.35D; + + @Override + public String embed(String text) { + double[] vector = buildVector(text); + return Arrays.stream(vector) + .mapToObj(value -> String.format(Locale.ROOT, "%.6f", value)) + .collect(Collectors.joining(",")); + } + + @Override + public double similarity(String text, String serializedVector) { + if (serializedVector == null || serializedVector.isBlank()) { + return 0D; + } + double[] left = buildVector(text); + double[] right = parseVector(serializedVector); + if (left.length != right.length || left.length == 0) { + return 0D; + } + + double dot = 0D; + for (int i = 0; i < left.length; i++) { + dot += left[i] * right[i]; + } + return dot; + } + + private double[] buildVector(String text) { + double[] vector = new double[DIMENSIONS]; + if (text == null || text.isBlank()) { + return vector; + } + + TOKEN_SPLITTER.splitAsStream(text.toLowerCase(Locale.ROOT)) + .map(String::trim) + .filter(token -> !token.isBlank()) + .forEach(token -> { + addTokenWeight(vector, token, 1D + Math.min(token.length(), 12) / 12D); + addCharacterNgrams(vector, token); + }); + + normalize(vector); + return vector; + } + + private void addTokenWeight(double[] vector, String token, double weight) { + int hash = token.hashCode(); + int index = Math.floorMod(hash, DIMENSIONS); + vector[index] += weight; + } + + private void addCharacterNgrams(double[] vector, String token) { + if (token.length() < 3) { + return; + } + for (int i = 0; i <= token.length() - 3; i++) { + String trigram = token.substring(i, i + 3); + addTokenWeight(vector, trigram, NGRAM_WEIGHT); + } + } + + private double[] parseVector(String serializedVector) { + String[] parts = serializedVector.split(","); + double[] vector = new double[parts.length]; + for (int i = 0; i < parts.length; i++) { + vector[i] = Double.parseDouble(parts[i]); + } + normalize(vector); + return vector; + } + + private void normalize(double[] vector) { + double magnitude = 0D; + for (double value : vector) { + magnitude += value * value; + } + if (magnitude == 0D) { + return; + } + double norm = Math.sqrt(magnitude); + for (int i = 0; i < vector.length; i++) { + vector[i] = vector[i] / norm; + } + } +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java new file mode 100644 index 00000000..4eec4795 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.search; + +public interface SearchEmbeddingService { + String embed(String text); + + double similarity(String text, String serializedVector); +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java index d2eaec3c..d40fb09f 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java @@ -9,6 +9,7 @@ public record SkillSearchDocument( String summary, String keywords, String searchText, + String semanticVector, String visibility, String status ) {} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java deleted file mode 100644 index 882b9d46..00000000 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.iflytek.skillhub.search.event; - -import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; - -@Component -public class DownloadCountEventListener { - - private final SkillRepository skillRepository; - - public DownloadCountEventListener(SkillRepository skillRepository) { - this.skillRepository = skillRepository; - } - - @EventListener - @Async("skillhubEventExecutor") - public void onSkillDownloaded(SkillDownloadedEvent event) { - skillRepository.incrementDownloadCount(event.skillId()); - } -} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java index 9bfe64dc..b856b82a 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SkillSearchDocument; import org.springframework.stereotype.Service; @@ -14,9 +15,12 @@ public class PostgresFullTextIndexService implements SearchIndexService { private final SkillSearchDocumentJpaRepository repository; + private final SearchEmbeddingService searchEmbeddingService; - public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository) { + public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository, + SearchEmbeddingService searchEmbeddingService) { this.repository = repository; + this.searchEmbeddingService = searchEmbeddingService; } @Override @@ -33,6 +37,7 @@ public void index(SkillSearchDocument document) { entity.setSummary(document.summary()); entity.setKeywords(document.keywords()); entity.setSearchText(document.searchText()); + entity.setSemanticVector(buildSemanticVector(document)); entity.setVisibility(document.visibility()); entity.setStatus(document.status()); repository.save(entity); @@ -46,6 +51,7 @@ public void index(SkillSearchDocument document) { document.summary(), document.keywords(), document.searchText(), + buildSemanticVector(document), document.visibility(), document.status() ); @@ -66,4 +72,17 @@ public void batchIndex(List documents) { public void remove(Long skillId) { repository.deleteBySkillId(skillId); } + + private String buildSemanticVector(SkillSearchDocument document) { + return searchEmbeddingService.embed(String.join("\n", + safe(document.title()), + safe(document.title()), + safe(document.summary()), + safe(document.keywords()), + safe(document.searchText()))); + } + + private String safe(String value) { + return value == null ? "" : value; + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 12727810..158bcb25 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -1,26 +1,84 @@ package com.iflytek.skillhub.search.postgres; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; import com.iflytek.skillhub.search.SearchResult; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import java.util.Comparator; +import java.util.HashMap; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; @Service public class PostgresFullTextQueryService implements SearchQueryService { + private static final Pattern QUERY_TERM_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+"); + private static final int MAX_QUERY_TERMS = 8; + private static final int SHORT_PREFIX_LENGTH = 2; + private static final String TITLE_VECTOR_SQL = "to_tsvector('simple', coalesce(title, ''))"; + private static final String TITLE_SQL = "LOWER(title)"; private final EntityManager entityManager; + private final SkillSearchDocumentJpaRepository searchDocumentRepository; + private final SearchEmbeddingService searchEmbeddingService; + private final boolean semanticEnabled; + private final double semanticWeight; + private final int candidateMultiplier; + private final int maxCandidates; public PostgresFullTextQueryService(EntityManager entityManager) { + this(entityManager, null, null, false, 0.35D, 8, 120); + } + + @Autowired + public PostgresFullTextQueryService(EntityManager entityManager, + SkillSearchDocumentJpaRepository searchDocumentRepository, + SearchEmbeddingService searchEmbeddingService, + @Value("${skillhub.search.semantic.enabled:true}") boolean semanticEnabled, + @Value("${skillhub.search.semantic.weight:0.35}") double semanticWeight, + @Value("${skillhub.search.semantic.candidate-multiplier:8}") int candidateMultiplier, + @Value("${skillhub.search.semantic.max-candidates:120}") int maxCandidates) { this.entityManager = entityManager; + this.searchDocumentRepository = searchDocumentRepository; + this.searchEmbeddingService = searchEmbeddingService; + this.semanticEnabled = semanticEnabled; + this.semanticWeight = semanticWeight; + this.candidateMultiplier = candidateMultiplier; + this.maxCandidates = maxCandidates; } @Override public SearchResult search(SearchQuery query) { + String normalizedKeyword = normalizeKeyword(query.keyword()); + String tsQuery = buildPrefixTsQuery(normalizedKeyword); + boolean hasKeyword = normalizedKeyword != null; + boolean hasTsQuery = tsQuery != null; + boolean useRelevanceOrdering = "relevance".equals(query.sortBy()) && hasKeyword; + boolean useShortPrefixTitleSearch = hasTsQuery && normalizedKeyword.length() <= SHORT_PREFIX_LENGTH; + boolean useSemanticRerank = semanticEnabled + && hasKeyword + && "relevance".equals(query.sortBy()) + && searchDocumentRepository != null + && searchEmbeddingService != null; + int requestedOffset = query.page() * query.size(); + if (useSemanticRerank && requestedOffset + query.size() > maxCandidates) { + useSemanticRerank = false; + } + int sqlLimit = query.size(); + int sqlOffset = requestedOffset; + if (useSemanticRerank) { + sqlLimit = Math.min(Math.max((query.page() + 1) * query.size() * candidateMultiplier, query.size() * candidateMultiplier), maxCandidates); + sqlOffset = 0; + } Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); @@ -48,21 +106,45 @@ public SearchResult search(SearchQuery query) { } // Full-text search - if (query.keyword() != null && !query.keyword().isBlank()) { - sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + if (hasKeyword) { + sql.append("AND ("); + if (hasTsQuery) { + if (useShortPrefixTitleSearch) { + sql.append(TITLE_VECTOR_SQL).append(" @@ to_tsquery('simple', :tsQuery) "); + } else { + sql.append("search_vector @@ to_tsquery('simple', :tsQuery) "); + } + sql.append(" OR "); + } + sql.append(TITLE_SQL).append(" LIKE :titleLike"); + sql.append(") "); } // Sorting if ("downloads".equals(query.sortBy())) { - sql.append("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC "); + sql.append("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC, "); + sql.append("(SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC "); } else if ("rating".equals(query.sortBy())) { - sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC "); + sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC, "); + sql.append("(SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC "); } else if ("newest".equals(query.sortBy())) { - sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC "); - } else if ("relevance".equals(query.sortBy()) && query.keyword() != null && !query.keyword().isBlank()) { - sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC "); + sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC "); + } else if (useRelevanceOrdering) { + sql.append("ORDER BY CASE "); + sql.append("WHEN ").append(TITLE_SQL).append(" = :titleExact THEN 4 "); + sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titlePrefix THEN 3 "); + sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titleLike THEN 2 "); + sql.append("ELSE 1 END DESC, "); + if (useShortPrefixTitleSearch) { + sql.append("ts_rank_cd(").append(TITLE_VECTOR_SQL) + .append(", to_tsquery('simple', :tsQuery)) DESC, updated_at DESC, skill_id DESC "); + } else if (hasTsQuery) { + sql.append("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC, skill_id DESC "); + } else { + sql.append("updated_at DESC, skill_id DESC "); + } } else { - sql.append("ORDER BY updated_at DESC "); + sql.append("ORDER BY updated_at DESC, skill_id DESC "); } // Pagination @@ -80,12 +162,19 @@ public SearchResult search(SearchQuery query) { nativeQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - nativeQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (hasTsQuery) { + nativeQuery.setParameter("tsQuery", tsQuery); + } + if (useRelevanceOrdering) { + nativeQuery.setParameter("titleExact", normalizedKeyword.toLowerCase()); + nativeQuery.setParameter("titlePrefix", normalizedKeyword.toLowerCase() + "%"); + } + nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } - nativeQuery.setParameter("limit", query.size()); - nativeQuery.setParameter("offset", query.page() * query.size()); + nativeQuery.setParameter("limit", sqlLimit); + nativeQuery.setParameter("offset", sqlOffset); @SuppressWarnings("unchecked") List skillIds = (List) nativeQuery.getResultList().stream() @@ -115,12 +204,110 @@ public SearchResult search(SearchQuery query) { countQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - countQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (hasTsQuery) { + countQuery.setParameter("tsQuery", tsQuery); + } + countQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } long total = ((Number) countQuery.getSingleResult()).longValue(); + if (useSemanticRerank && !skillIds.isEmpty()) { + skillIds = rerankBySemanticSimilarity(skillIds, normalizedKeyword, requestedOffset, query.size()); + } + return new SearchResult(skillIds, total, query.page(), query.size()); } + + private List rerankBySemanticSimilarity(List candidateSkillIds, + String normalizedKeyword, + int requestedOffset, + int pageSize) { + Map documentsBySkillId = new HashMap<>(); + for (SkillSearchDocumentEntity entity : searchDocumentRepository.findBySkillIdIn(candidateSkillIds)) { + documentsBySkillId.put(entity.getSkillId(), entity); + } + + int totalCandidates = Math.max(candidateSkillIds.size(), 1); + List rankedSkills = new java.util.ArrayList<>(candidateSkillIds.size()); + for (int index = 0; index < candidateSkillIds.size(); index++) { + Long skillId = candidateSkillIds.get(index); + SkillSearchDocumentEntity entity = documentsBySkillId.get(skillId); + double baseScore = 1D - (index / (double) totalCandidates); + double semanticScore = computeSemanticScore(normalizedKeyword, entity); + double combinedScore = (baseScore * (1D - semanticWeight)) + (semanticScore * semanticWeight); + rankedSkills.add(new RankedSkill(skillId, combinedScore)); + } + + return rankedSkills.stream() + .sorted(Comparator.comparingDouble(RankedSkill::score).reversed()) + .skip(requestedOffset) + .limit(pageSize) + .map(RankedSkill::skillId) + .toList(); + } + + private double computeSemanticScore(String normalizedKeyword, SkillSearchDocumentEntity entity) { + if (entity == null) { + return 0D; + } + String serializedVector = entity.getSemanticVector(); + if (serializedVector == null || serializedVector.isBlank()) { + serializedVector = searchEmbeddingService.embed(String.join("\n", + safe(entity.getTitle()), + safe(entity.getSummary()), + safe(entity.getKeywords()), + safe(entity.getSearchText()))); + } + return searchEmbeddingService.similarity(normalizedKeyword, serializedVector); + } + + private String safe(String value) { + return value == null ? "" : value; + } + + private String normalizeKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim().toLowerCase(); + } + + private String buildPrefixTsQuery(String keyword) { + if (keyword == null) { + return null; + } + + List terms = QUERY_TERM_SPLITTER.splitAsStream(keyword.toLowerCase()) + .map(String::trim) + .filter(term -> !term.isBlank()) + .distinct() + .limit(MAX_QUERY_TERMS) + .toList(); + + if (terms.isEmpty()) { + return null; + } + + List tsQueryTerms = terms.stream() + .filter(this::isTsQueryCompatibleTerm) + .toList(); + + if (tsQueryTerms.isEmpty()) { + return null; + } + + return tsQueryTerms.stream() + .map(term -> term + ":*") + .reduce((left, right) -> left + " & " + right) + .orElse(null); + } + + private boolean isTsQueryCompatibleTerm(String term) { + return term.chars().anyMatch(ch -> Character.isLetter(ch) || ch == '_'); + } + + private record RankedSkill(Long skillId, double score) { + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 7d1a0b2e..59bd51ef 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -88,6 +88,7 @@ private Optional toDocument(Skill skill) { skill.getSummary(), "", searchText, + null, skill.getVisibility().name(), skill.getStatus().name() )); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java new file mode 100644 index 00000000..c0412a16 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.search; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class HashingSearchEmbeddingServiceTest { + + private final HashingSearchEmbeddingService service = new HashingSearchEmbeddingService(); + + @Test + void embedShouldBeDeterministic() { + String first = service.embed("self improving skill"); + String second = service.embed("self improving skill"); + + assertThat(first).isEqualTo(second); + } + + @Test + void similarityShouldFavorCloserText() { + String relevantVector = service.embed("self improvement productivity habit tracker"); + String noisyVector = service.embed("web search keywords company research"); + + double relevant = service.similarity("self improvement", relevantVector); + double noisy = service.similarity("self improvement", noisyVector); + + assertThat(relevant).isGreaterThan(noisy); + } + + @Test + void similarityShouldHandleSingularAndPluralForms() { + String pluralVector = service.embed("build strong habits with daily practice"); + String unrelatedVector = service.embed("research company profiles on the web"); + + double pluralMatch = service.similarity("habit", pluralVector); + double unrelatedMatch = service.similarity("habit", unrelatedVector); + + assertThat(pluralMatch).isGreaterThan(unrelatedMatch); + } +} diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java new file mode 100644 index 00000000..5ca138e6 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -0,0 +1,356 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.HashingSearchEmbeddingService; +import com.iflytek.skillhub.search.SearchQuery; +import com.iflytek.skillhub.search.SearchVisibilityScope; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PostgresFullTextQueryServiceTest { + + @Test + void shortKeywordsShouldUsePrefixTsQuery() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "ai", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("tsQuery", "ai:*"); + verify(countQuery).setParameter("tsQuery", "ai:*"); + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("to_tsvector('simple', coalesce(title, '')) @@ to_tsquery('simple', :tsQuery)"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE :titleLike"); + } + + @Test + void longerKeywordsShouldUsePrefixTsQuery() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "agent", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("tsQuery", "agent:*"); + verify(countQuery).setParameter("tsQuery", "agent:*"); + } + + @Test + void prefixSearchSqlShouldUseVectorRanking() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "sel", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("search_vector @@ to_tsquery('simple', :tsQuery)"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery))"); + } + + @Test + void shortPrefixRelevanceShouldRankUsingTitleVectorWithoutDuplicateOrderBy() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "x", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(to_tsvector('simple', coalesce(title, '')), to_tsquery('simple', :tsQuery))"); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ORDER BY ORDER BY"); + } + + @Test + void multipleTermsShouldBuildPrefixQueryForEachLexeme() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "self improving", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("tsQuery", "self:* & improving:*"); + verify(countQuery).setParameter("tsQuery", "self:* & improving:*"); + } + + @Test + void numericKeywordsShouldFallbackToTitleLikeSearchWithoutTsQuery() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "122222222222222222222222", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("tsQuery"), anyString()); + verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("tsQuery"), anyString()); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("to_tsquery('simple', :tsQuery)"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE :titleLike"); + } + + @Test + void downloadsSortShouldNotBindRelevanceOnlyParameters() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "51222222333", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "downloads", + 0, + 12 + )); + + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("titleExact"), anyString()); + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("titlePrefix"), anyString()); + verify(nativeQuery).setParameter("titleLike", "%51222222333%"); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC, (SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC"); + } + + @Test + void emptyKeywordRelevanceShouldUseStableNewestOrdering() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + null, + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 12 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("ORDER BY updated_at DESC, skill_id DESC"); + } + + @Test + void semanticRerankShouldPromoteSemanticallyRelevantCandidate() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class); + HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService(); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(2L, 1L)); + when(countQuery.getSingleResult()).thenReturn(2L); + when(repository.findBySkillIdIn(List.of(2L, 1L))).thenReturn(List.of( + new SkillSearchDocumentEntity(1L, 1L, "global", "user-1", "Self Improvement Coach", + "Build better habits", "habits,self improvement", "habit tracker and self improvement guide", + embeddingService.embed("habit tracker and self improvement guide"), "PUBLIC", "ACTIVE"), + new SkillSearchDocumentEntity(2L, 1L, "global", "user-2", "Web Search Exa", + "Research assistant", "keywords,search", "web search keywords company research", + embeddingService.embed("web search keywords company research"), "PUBLIC", "ACTIVE") + )); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService( + entityManager, + repository, + embeddingService, + true, + 0.6D, + 8, + 120 + ); + + var result = service.search(new SearchQuery( + "self improvement", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 2 + )); + + verify(nativeQuery).setParameter("limit", 16); + verify(nativeQuery).setParameter("offset", 0); + assertThat(result.skillIds()).containsExactly(1L, 2L); + } + + @Test + void deepSemanticPagesShouldFallBackToDatabasePagination() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class); + HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService(); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(201L, 202L)); + when(countQuery.getSingleResult()).thenReturn(1000L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService( + entityManager, + repository, + embeddingService, + true, + 0.6D, + 8, + 120 + ); + + var result = service.search(new SearchQuery( + "self improvement", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 20, + 10 + )); + + verify(nativeQuery).setParameter("limit", 10); + verify(nativeQuery).setParameter("offset", 200); + verify(repository, never()).findBySkillIdIn(org.mockito.ArgumentMatchers.anyList()); + assertThat(result.skillIds()).containsExactly(201L, 202L); + assertThat(result.total()).isEqualTo(1000L); + } +} diff --git a/server/skillhub-search/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/server/skillhub-search/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..fdbd0b15 --- /dev/null +++ b/server/skillhub-search/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/server/skillhub-storage/pom.xml b/server/skillhub-storage/pom.xml index 2aeb5cc6..ef27bb63 100644 --- a/server/skillhub-storage/pom.xml +++ b/server/skillhub-storage/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.7 skillhub-storage @@ -25,6 +25,11 @@ s3 2.20.26 + + software.amazon.awssdk + apache-client + 2.20.26 + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index 26889aa0..ba87c7be 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -6,7 +6,7 @@ import java.io.*; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; -import java.time.Instant; +import java.time.Duration; import java.util.List; @Service @@ -28,19 +28,19 @@ public void putObject(String key, InputStream data, long size, String contentTyp data.transferTo(out); } Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { throw new UncheckedIOException("Failed to put object: " + key, e); } + } catch (IOException e) { throw new StorageAccessException("putObject", key, e); } } @Override public InputStream getObject(String key) { try { return Files.newInputStream(resolve(key)); } - catch (IOException e) { throw new UncheckedIOException("Failed to get object: " + key, e); } + catch (IOException e) { throw new StorageAccessException("getObject", key, e); } } @Override public void deleteObject(String key) { try { Files.deleteIfExists(resolve(key)); } - catch (IOException e) { throw new UncheckedIOException("Failed to delete object: " + key, e); } + catch (IOException e) { throw new StorageAccessException("deleteObject", key, e); } } @Override @@ -55,10 +55,20 @@ public ObjectMetadata getMetadata(String key) { Path path = resolve(key); BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); return new ObjectMetadata(attrs.size(), Files.probeContentType(path), attrs.lastModifiedTime().toInstant()); - } catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); } + } catch (IOException e) { throw new StorageAccessException("getMetadata", key, e); } + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + return null; } private Path resolve(String key) { + // Object keys use forward slashes; reject backslashes so traversal checks + // behave consistently across platforms. + if (key.contains("\\")) { + throw new IllegalArgumentException("Invalid storage key: " + key); + } Path resolved = basePath.resolve(key).normalize(); if (!resolved.startsWith(basePath)) { throw new IllegalArgumentException("Invalid storage key: " + key); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java index d5bb0b24..5873f36b 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.storage; import java.io.InputStream; +import java.time.Duration; import java.util.List; public interface ObjectStorageService { @@ -10,4 +11,5 @@ public interface ObjectStorageService { void deleteObjects(List keys); boolean exists(String key); ObjectMetadata getMetadata(String key); + String generatePresignedUrl(String key, Duration expiry, String downloadFilename); } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java index 05393e7d..0e50ef9e 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java @@ -3,17 +3,29 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.time.Duration; + @Component @ConfigurationProperties(prefix = "skillhub.storage.s3") public class S3StorageProperties { private String endpoint; + private String publicEndpoint; private String bucket = "skillhub"; private String accessKey; private String secretKey; private String region = "us-east-1"; + private boolean forcePathStyle = true; + private boolean autoCreateBucket = false; + private Duration presignExpiry = Duration.ofMinutes(10); + private Integer maxConnections = 100; + private Duration connectionAcquisitionTimeout = Duration.ofSeconds(2); + private Duration apiCallAttemptTimeout = Duration.ofSeconds(10); + private Duration apiCallTimeout = Duration.ofSeconds(30); public String getEndpoint() { return endpoint; } public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public String getPublicEndpoint() { return publicEndpoint; } + public void setPublicEndpoint(String publicEndpoint) { this.publicEndpoint = publicEndpoint; } public String getBucket() { return bucket; } public void setBucket(String bucket) { this.bucket = bucket; } public String getAccessKey() { return accessKey; } @@ -22,4 +34,18 @@ public class S3StorageProperties { public void setSecretKey(String secretKey) { this.secretKey = secretKey; } public String getRegion() { return region; } public void setRegion(String region) { this.region = region; } + public boolean isForcePathStyle() { return forcePathStyle; } + public void setForcePathStyle(boolean forcePathStyle) { this.forcePathStyle = forcePathStyle; } + public boolean isAutoCreateBucket() { return autoCreateBucket; } + public void setAutoCreateBucket(boolean autoCreateBucket) { this.autoCreateBucket = autoCreateBucket; } + public Duration getPresignExpiry() { return presignExpiry; } + public void setPresignExpiry(Duration presignExpiry) { this.presignExpiry = presignExpiry; } + public Integer getMaxConnections() { return maxConnections; } + public void setMaxConnections(Integer maxConnections) { this.maxConnections = maxConnections; } + public Duration getConnectionAcquisitionTimeout() { return connectionAcquisitionTimeout; } + public void setConnectionAcquisitionTimeout(Duration connectionAcquisitionTimeout) { this.connectionAcquisitionTimeout = connectionAcquisitionTimeout; } + public Duration getApiCallAttemptTimeout() { return apiCallAttemptTimeout; } + public void setApiCallAttemptTimeout(Duration apiCallAttemptTimeout) { this.apiCallAttemptTimeout = apiCallAttemptTimeout; } + public Duration getApiCallTimeout() { return apiCallTimeout; } + public void setApiCallTimeout(Duration apiCallTimeout) { this.apiCallTimeout = apiCallTimeout; } } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index 433ef50e..ae50486c 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -8,12 +8,18 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import java.io.InputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; @Service @@ -22,24 +28,46 @@ public class S3StorageService implements ObjectStorageService { private static final Logger log = LoggerFactory.getLogger(S3StorageService.class); private final S3StorageProperties properties; private S3Client s3Client; + private S3Presigner s3Presigner; public S3StorageService(S3StorageProperties properties) { this.properties = properties; } @PostConstruct void init() { + ApacheHttpClient.Builder httpClientBuilder = ApacheHttpClient.builder() + .maxConnections(properties.getMaxConnections()) + .connectionAcquisitionTimeout(properties.getConnectionAcquisitionTimeout()); var builder = S3Client.builder() .region(Region.of(properties.getRegion())) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) - .forcePathStyle(true); + .forcePathStyle(properties.isForcePathStyle()) + .httpClientBuilder(httpClientBuilder) + .overrideConfiguration(config -> config + .apiCallAttemptTimeout(properties.getApiCallAttemptTimeout()) + .apiCallTimeout(properties.getApiCallTimeout())); if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { builder.endpointOverride(URI.create(properties.getEndpoint())); } this.s3Client = builder.build(); + var presignerBuilder = S3Presigner.builder() + .region(Region.of(properties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))); + if (properties.getPublicEndpoint() != null && !properties.getPublicEndpoint().isBlank()) { + presignerBuilder.endpointOverride(URI.create(properties.getPublicEndpoint())); + } else if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { + presignerBuilder.endpointOverride(URI.create(properties.getEndpoint())); + } + this.s3Presigner = presignerBuilder.build(); ensureBucketExists(); } private void ensureBucketExists() { + if (!properties.isAutoCreateBucket()) { + s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); + return; + } try { s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); } catch (NoSuchBucketException e) { log.info("Bucket '{}' does not exist, creating...", properties.getBucket()); @@ -48,30 +76,75 @@ private void ensureBucketExists() { } @Override public void putObject(String key, InputStream data, long size, String contentType) { - s3Client.putObject(PutObjectRequest.builder().bucket(properties.getBucket()).key(key).contentType(contentType).contentLength(size).build(), RequestBody.fromInputStream(data, size)); + try { + s3Client.putObject(PutObjectRequest.builder().bucket(properties.getBucket()).key(key).contentType(contentType).contentLength(size).build(), RequestBody.fromInputStream(data, size)); + } catch (RuntimeException e) { + throw new StorageAccessException("putObject", key, e); + } } @Override public InputStream getObject(String key) { - return s3Client.getObject(GetObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); + try { + return s3Client.getObject(GetObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); + } catch (RuntimeException e) { + throw new StorageAccessException("getObject", key, e); + } } @Override public void deleteObject(String key) { - s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); + try { + s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); + } catch (RuntimeException e) { + throw new StorageAccessException("deleteObject", key, e); + } } @Override public void deleteObjects(List keys) { if (keys.isEmpty()) return; - List ids = keys.stream().map(k -> ObjectIdentifier.builder().key(k).build()).toList(); - s3Client.deleteObjects(DeleteObjectsRequest.builder().bucket(properties.getBucket()).delete(Delete.builder().objects(ids).build()).build()); + try { + List ids = keys.stream().map(k -> ObjectIdentifier.builder().key(k).build()).toList(); + s3Client.deleteObjects(DeleteObjectsRequest.builder().bucket(properties.getBucket()).delete(Delete.builder().objects(ids).build()).build()); + } catch (RuntimeException e) { + throw new StorageAccessException("deleteObjects", String.join(",", keys), e); + } } @Override public boolean exists(String key) { try { s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return true; } catch (NoSuchKeyException e) { return false; } + catch (RuntimeException e) { throw new StorageAccessException("exists", key, e); } } @Override public ObjectMetadata getMetadata(String key) { - HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); - return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified()); + try { + HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); + return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified()); + } catch (RuntimeException e) { + throw new StorageAccessException("getMetadata", key, e); + } + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + Duration signatureDuration = expiry != null ? expiry : properties.getPresignExpiry(); + String contentDisposition = downloadFilename == null || downloadFilename.isBlank() + ? "attachment" + : "attachment; filename*=UTF-8''" + java.net.URLEncoder.encode(downloadFilename, StandardCharsets.UTF_8) + .replace("+", "%20"); + try { + PresignedGetObjectRequest request = s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(signatureDuration) + .getObjectRequest(GetObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .responseContentDisposition(contentDisposition) + .build()) + .build() + ); + return request.url().toString(); + } catch (RuntimeException e) { + throw new StorageAccessException("generatePresignedUrl", key, e); + } } } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java new file mode 100644 index 00000000..81f7db15 --- /dev/null +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.storage; + +public class StorageAccessException extends RuntimeException { + + private final String operation; + private final String key; + + public StorageAccessException(String operation, String key, Throwable cause) { + super("Storage operation failed: " + operation + " [" + key + "]", cause); + this.operation = operation; + this.key = key; + } + + public String getOperation() { + return operation; + } + + public String getKey() { + return key; + } +} diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java index d2e98611..51e9aa5e 100644 --- a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java +++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java @@ -7,13 +7,24 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class LocalFileStorageServiceTest { - @TempDir Path tempDir; + + @TempDir + Path tempDir; + private LocalFileStorageService storageService; @BeforeEach @@ -23,21 +34,26 @@ void setUp() { storageService = new LocalFileStorageService(props); } - @Test void shouldPutAndGetObject() throws Exception { + @Test + void shouldPutAndGetObject() throws Exception { String key = "skills/1/1/SKILL.md"; byte[] content = "# Hello".getBytes(StandardCharsets.UTF_8); storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/markdown"); - try (InputStream result = storageService.getObject(key)) { assertArrayEquals(content, result.readAllBytes()); } + try (InputStream result = storageService.getObject(key)) { + assertArrayEquals(content, result.readAllBytes()); + } } - @Test void shouldCheckExistence() { + @Test + void shouldCheckExistence() { assertFalse(storageService.exists("test/exists.txt")); byte[] content = "data".getBytes(StandardCharsets.UTF_8); storageService.putObject("test/exists.txt", new ByteArrayInputStream(content), content.length, "text/plain"); assertTrue(storageService.exists("test/exists.txt")); } - @Test void shouldDeleteObject() { + @Test + void shouldDeleteObject() { byte[] content = "data".getBytes(StandardCharsets.UTF_8); storageService.putObject("test/delete.txt", new ByteArrayInputStream(content), content.length, "text/plain"); assertTrue(storageService.exists("test/delete.txt")); @@ -45,7 +61,8 @@ void setUp() { assertFalse(storageService.exists("test/delete.txt")); } - @Test void shouldDeleteMultipleObjects() { + @Test + void shouldDeleteMultipleObjects() { byte[] content = "data".getBytes(StandardCharsets.UTF_8); storageService.putObject("a/1.txt", new ByteArrayInputStream(content), content.length, "text/plain"); storageService.putObject("a/2.txt", new ByteArrayInputStream(content), content.length, "text/plain"); @@ -54,7 +71,8 @@ void setUp() { assertFalse(storageService.exists("a/2.txt")); } - @Test void shouldGetMetadata() { + @Test + void shouldGetMetadata() { byte[] content = "hello world".getBytes(StandardCharsets.UTF_8); storageService.putObject("test/meta.txt", new ByteArrayInputStream(content), content.length, "text/plain"); ObjectMetadata metadata = storageService.getMetadata("test/meta.txt"); @@ -62,19 +80,31 @@ void setUp() { assertNotNull(metadata.lastModified()); } - @Test void shouldRejectPathTraversalKeys() { + @Test + void shouldRejectPathTraversalKeys() { byte[] content = "data".getBytes(StandardCharsets.UTF_8); IllegalArgumentException putError = assertThrows( - IllegalArgumentException.class, - () -> storageService.putObject("../escape.txt", new ByteArrayInputStream(content), content.length, "text/plain") + IllegalArgumentException.class, + () -> storageService.putObject("../escape.txt", new ByteArrayInputStream(content), content.length, "text/plain") ); assertEquals("Invalid storage key: ../escape.txt", putError.getMessage()); IllegalArgumentException getError = assertThrows( - IllegalArgumentException.class, - () -> storageService.getObject("..\\escape.txt") + IllegalArgumentException.class, + () -> storageService.getObject("..\\escape.txt") ); assertEquals("Invalid storage key: ..\\escape.txt", getError.getMessage()); } + + @Test + void generatePresignedUrlReturnsNullForLocalStorage() throws Exception { + StorageProperties properties = new StorageProperties(); + properties.getLocal().setBasePath(tempDir.toString()); + Files.createDirectories(tempDir); + + LocalFileStorageService service = new LocalFileStorageService(properties); + + assertThat(service.generatePresignedUrl("packages/demo.zip", Duration.ofMinutes(10), "demo.zip")).isNull(); + } } diff --git a/skillhub-logo.svg b/skillhub-logo.svg new file mode 100644 index 00000000..1a9b3b62 --- /dev/null +++ b/skillhub-logo.svg @@ -0,0 +1 @@ +S \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile index b5ea803f..f8ce1617 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -8,7 +8,11 @@ RUN pnpm build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/src/docs/skill.md /usr/share/nginx/html/registry/skill.md +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY runtime-config.js.template /usr/share/nginx/html/runtime-config.js.template +COPY docker-entrypoint.d/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh EXPOSE 80 HEALTHCHECK --interval=10s --timeout=3s \ - CMD wget -qO- http://localhost/nginx-health || exit 1 + CMD wget -qO- http://127.0.0.1/nginx-health || exit 1 diff --git a/web/LANDING_PAGE_REDESIGN.md b/web/LANDING_PAGE_REDESIGN.md new file mode 100644 index 00000000..dc63b27c --- /dev/null +++ b/web/LANDING_PAGE_REDESIGN.md @@ -0,0 +1,75 @@ +# SkillHub 落地页重新设计 + +## 设计理念 + +采用"技术编织"(Tech Weave)美学风格,通过以下元素传达 SkillHub 作为企业级技能注册中心的专业性和创新性: + +### 视觉特点 + +1. **动态粒子系统** + - Canvas 实现的粒子连接动画 + - 象征技能之间的连接和协作 + - 80个粒子节点,动态连线效果 + +2. **配色方案** + - 深蓝紫色基调(slate-950, indigo-950) + - 霓虹青色点缀(cyan-500) + - 紫罗兰色辅助(violet-500) + - 营造科技感和未来感 + +3. **字体选择** + - 标题:Syne(几何感强,现代) + - 正文:IBM Plex Sans(技术感,易读) + - 代码:JetBrains Mono + +4. **动效设计** + - 渐入动画(fade-up, fade-in) + - 悬浮卡片效果(hover:scale-105) + - 渐变光晕(gradient glow) + - 脉冲动画(pulse) + +### 内容结构 + +1. **Hero 区域** + - 大标题 + 渐变文字效果 + - 搜索栏(带光晕效果) + - CTA 按钮(探索技能 / 发布技能) + - 统计数据展示(技能包、下载量、团队) + +2. **特性展示** + - 6个核心特性卡片 + - 图标 + 标题 + 描述 + - 悬浮交互效果 + +3. **CTA 区域** + - 快速开始指引 + - 命令行示例 + - 行动按钮 + +4. **页脚** + - 版权信息 + - 导航链接 + +## 技术实现 + +- React + TypeScript +- TailwindCSS +- Canvas API(粒子动画) +- TanStack Router + +## 文件变更 + +- 新增:`web/src/pages/landing.tsx` - 新的落地页组件 +- 修改:`web/src/app/router.tsx` - 路由配置,将 landing 设为首页 +- 修改:`web/index.html` - 更新字体引用 +- 修改:`web/src/index.css` - 更新字体配置 + +## 本地预览 + +```bash +cd web +npm install --legacy-peer-deps +npm run dev +``` + +访问 `http://localhost:5173` 查看新的落地页。 diff --git a/web/PREVIEW.md b/web/PREVIEW.md new file mode 100644 index 00000000..e595ea5c --- /dev/null +++ b/web/PREVIEW.md @@ -0,0 +1,55 @@ +# 落地页预览说明 + +## 如何查看 + +1. 启动开发服务器: +```bash +cd web +npm run dev +``` + +2. 访问 `http://localhost:5173` + +## 页面特性 + +### 动态效果 +- 背景粒子动画会自动运行 +- 鼠标悬停在特性卡片上会有缩放和颜色变化 +- 按钮有渐变和阴影效果 +- 搜索栏有光晕效果 + +### 响应式设计 +- 移动端:单列布局,较小字体 +- 平板:2列特性卡片 +- 桌面:3列特性卡片,大标题 + +### 交互元素 +- "探索技能" 按钮 → 跳转到搜索页面 +- "发布技能" 按钮 → 跳转到发布页面 +- 搜索栏 → 输入后跳转到搜索结果 +- 统计数字 → 悬停有渐变效果 + +## 与原首页的区别 + +| 特性 | 原首页 | 新落地页 | +|------|--------|----------| +| 主要内容 | 技能列表(热门/最新) | 产品介绍和特性展示 | +| 视觉风格 | 简洁卡片式 | 科技感渐变 + 粒子动画 | +| 配色 | 浅色为主 | 深色主题 + 霓虹色 | +| 目标 | 快速浏览技能 | 吸引新用户了解产品 | +| 路由 | `/` | `/`(新),原首页移至 `/skills` | + +## 设计亮点 + +1. **粒子连接动画**:80个粒子节点,距离小于120px时自动连线,透明度随距离变化 +2. **渐变文字**:标题使用 cyan → blue → violet 三色渐变 +3. **光晕效果**:搜索栏和背景有模糊的光晕装饰 +4. **分层动画**:不同元素有不同的延迟时间,形成层次感 +5. **悬浮交互**:卡片悬停时会上浮并显示边框高亮 + +## 技术细节 + +- 粒子系统使用 Canvas 2D API +- 动画使用 CSS animations 和 transitions +- 响应式使用 Tailwind 的 breakpoint 系统 +- 字体通过 Google Fonts CDN 加载 diff --git a/web/TODO.md b/web/TODO.md new file mode 100644 index 00000000..8351aa62 --- /dev/null +++ b/web/TODO.md @@ -0,0 +1,71 @@ +# SkillHub 前端待办事项 + +## 高优先级 + +### 1. i18n 语言切换 Bug +- **问题**: 切换到 English 没有反应 +- **位置**: `src/shared/components/language-switcher.tsx` +- **需要检查**: + - i18next 配置是否正确 + - 语言切换事件是否正确触发 + - 翻译文件是否正确加载 + +### 2. 完善 i18n 支持 +- **需要添加翻译的组件**: + - Toast 通知消息 + - ConfirmDialog 对话框 + - 发布页面的审核提示 + - 审核详情页面 + - Token 管理页面 + - 所有新增的 UI 文本 + +### 3. 统一异常处理 +- **需求**: 为所有 API 调用添加统一的错误处理 +- **实现方案**: + - 创建 API 拦截器 + - 自动显示错误 toast + - 处理 401/403 等特殊状态码 + - 统一错误消息格式 + +### 4. 完善 Toast 通知 +- **需要替换的地方**: + - 所有剩余的 `alert()` 调用 + - 所有剩余的 `window.confirm()` 调用 + - 添加加载状态的 toast + - 添加 promise toast 用于异步操作 + +## 中优先级 + +### 5. 审核详情页对话框 +- **位置**: `src/pages/dashboard/review-detail.tsx` +- **需要**: 完成 ConfirmDialog 的集成 +- **状态**: 部分完成,需要添加对话框到 JSX + +### 6. 错误边界 +- **需求**: 添加 React Error Boundary +- **功能**: 捕获组件错误并显示友好提示 + +### 7. 加载状态优化 +- **需求**: 统一加载状态的显示 +- **实现**: 创建全局加载组件 + +## 低优先级 + +### 8. 性能优化 +- 代码分割 +- 懒加载 +- 图片优化 + +### 9. 可访问性 +- ARIA 标签 +- 键盘导航 +- 屏幕阅读器支持 + +## 已完成 + +- ✅ 创建 Toast 通知系统 +- ✅ 创建 ConfirmDialog 组件 +- ✅ 发布页面添加审核提示 +- ✅ Token 列表使用 SPA 对话框 +- ✅ 发布页面使用 Toast 通知 +- ✅ 为用户添加 SKILL_ADMIN 角色 diff --git a/web/docker-entrypoint.d/30-runtime-config.sh b/web/docker-entrypoint.d/30-runtime-config.sh new file mode 100644 index 00000000..f7ed8872 --- /dev/null +++ b/web/docker-entrypoint.d/30-runtime-config.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +: "${SKILLHUB_WEB_API_BASE_URL:=}" +: "${SKILLHUB_PUBLIC_BASE_URL:=}" + +envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL}' \ + < /usr/share/nginx/html/runtime-config.js.template \ + > /usr/share/nginx/html/runtime-config.js diff --git a/web/index.html b/web/index.html index 3b775782..99024a11 100644 --- a/web/index.html +++ b/web/index.html @@ -3,10 +3,18 @@ + SkillHub + + + +
- + diff --git a/web/nginx.conf b/web/nginx.conf.template similarity index 59% rename from web/nginx.conf rename to web/nginx.conf.template index e08010f5..b1f54629 100644 --- a/web/nginx.conf +++ b/web/nginx.conf.template @@ -13,7 +13,7 @@ server { } location /api/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -21,19 +21,23 @@ server { } location /oauth2/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; } location /login/oauth2/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; } location /.well-known/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; } location /assets/ { @@ -41,6 +45,18 @@ server { add_header Cache-Control "public, immutable"; } + location = /registry/skill.md { + default_type text/plain; + add_header Content-Disposition "inline"; + add_header X-Content-Type-Options "nosniff"; + try_files $uri =404; + } + + location = /runtime-config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + location /nginx-health { return 200 'ok'; add_header Content-Type text/plain; diff --git a/web/package.json b/web/package.json index 78a40f98..32faa59b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,33 +1,44 @@ { "name": "skillhub-web", "private": true, - "version": "0.1.0", + "version": "0.1.0-beta.7", "type": "module", "scripts": { "install:ci": "pnpm install --frozen-lockfile", "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/inject-docs.mjs", "preview": "vite preview", + "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.64.0", "@tanstack/react-router": "^1.95.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.344.0", "openapi-fetch": "^0.13.8", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^15.0.0", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-sanitize": "^6.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.2.1", + "unist-util-visit": "^5.0.0", "zustand": "^5.0.11" }, "devDependencies": { + "@types/mdast": "^4.0.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", @@ -37,10 +48,12 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "marked": "^15.0.12", "openapi-typescript": "^7.6.1", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.2.4" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index dfa0bdf0..09873f84 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.64.0 version: 5.90.21(react@19.2.4) @@ -20,6 +23,12 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.1 + i18next: + specifier: ^25.8.18 + version: 25.8.18(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 lucide-react: specifier: ^0.344.0 version: 0.344.0(react@19.2.4) @@ -35,19 +44,40 @@ importers: react-dropzone: specifier: ^15.0.0 version: 15.0.0(react@19.2.4) + react-i18next: + specifier: ^16.5.8 + version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-frontmatter: + specifier: ^5.0.0 + version: 5.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^2.2.1 version: 2.6.1 + unist-util-visit: + specifier: ^5.0.0 + version: 5.1.0 zustand: specifier: ^5.0.11 version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -75,6 +105,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.5 version: 0.4.26(eslint@8.57.1) + marked: + specifier: ^15.0.12 + version: 15.0.12 openapi-typescript: specifier: ^7.6.1 version: 7.13.0(typescript@5.9.3) @@ -90,6 +123,9 @@ importers: vite: specifier: ^6.1.0 version: 6.4.1(jiti@1.21.7) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7) packages: @@ -168,6 +204,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -354,6 +394,21 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -395,6 +450,272 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -590,9 +911,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -689,6 +1016,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -731,10 +1087,18 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -776,6 +1140,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -790,6 +1158,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -809,6 +1181,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -870,6 +1246,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -877,6 +1257,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -897,6 +1280,9 @@ packages: electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -910,6 +1296,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-plugin-react-hooks@4.6.2: resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} engines: {node: '>=10'} @@ -954,10 +1344,17 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -977,6 +1374,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1009,6 +1409,10 @@ packages: flatted@3.4.1: resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1027,6 +1431,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1061,6 +1469,9 @@ packages: hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -1074,6 +1485,9 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -1081,6 +1495,17 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@25.8.18: + resolution: {integrity: sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1165,6 +1590,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1219,6 +1647,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -1230,9 +1661,44 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -1261,6 +1727,30 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -1422,6 +1912,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1520,6 +2017,22 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' + react-i18next@16.5.8: + resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1533,6 +2046,36 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -1547,12 +2090,24 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1613,10 +2168,19 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1624,6 +2188,12 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -1635,6 +2205,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -1682,10 +2255,28 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1758,6 +2349,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -1772,6 +2383,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1812,11 +2428,48 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1952,6 +2605,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2076,6 +2731,23 @@ snapshots: '@eslint/js@8.57.1': {} + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -2119,6 +2791,246 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/rect@1.1.1': {} + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -2279,10 +3191,17 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -2406,6 +3325,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1(jiti@1.21.7))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(jiti@1.21.7) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2440,8 +3401,14 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + array-union@2.1.0: {} + assertion-error@2.0.1: {} + attr-accept@2.2.5: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -2482,6 +3449,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -2490,6 +3459,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2505,6 +3482,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -2561,10 +3540,14 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} dequal@2.0.3: {} + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -2583,6 +3566,8 @@ snapshots: electron-to-chromium@1.5.307: {} + es-module-lexer@1.7.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2616,6 +3601,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -2692,8 +3679,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -2714,6 +3707,10 @@ snapshots: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2743,6 +3740,8 @@ snapshots: flatted@3.4.1: {} + format@0.2.2: {} + fraction.js@5.3.4: {} fs.realpath@1.0.0: {} @@ -2754,6 +3753,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2796,6 +3797,12 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -2829,6 +3836,10 @@ snapshots: highlight.js@11.11.1: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} https-proxy-agent@7.0.6(supports-color@10.2.2): @@ -2838,6 +3849,16 @@ snapshots: transitivePeerDependencies: - supports-color + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.28.6 + + i18next@25.8.18(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -2899,6 +3920,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -2940,6 +3963,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -2954,6 +3979,21 @@ snapshots: dependencies: react: 19.2.4 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-table@3.0.4: {} + + marked@15.0.12: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -2971,6 +4011,74 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -3064,6 +4172,71 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -3282,6 +4455,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3357,6 +4534,17 @@ snapshots: prop-types: 15.8.1 react: 19.2.4 + react-i18next@16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.18(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + react-is@16.13.1: {} react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): @@ -3379,6 +4567,33 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react@19.2.4: {} read-cache@1.0.0: @@ -3397,6 +4612,31 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -3414,6 +4654,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -3483,12 +4729,23 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + slash@3.0.0: {} + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -3500,6 +4757,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -3570,11 +4831,21 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3651,6 +4922,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 @@ -3667,6 +4953,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(jiti@1.21.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(jiti@1.21.7) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.1(jiti@1.21.7): dependencies: esbuild: 0.25.12 @@ -3679,10 +4986,58 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 + vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(jiti@1.21.7)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(jiti@1.21.7) + vite-node: 3.2.4(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + void-elements@3.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 00000000..1a9b3b62 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ +S \ No newline at end of file diff --git a/web/public/runtime-config.js b/web/public/runtime-config.js new file mode 100644 index 00000000..88be7ca4 --- /dev/null +++ b/web/public/runtime-config.js @@ -0,0 +1,9 @@ +window.__SKILLHUB_RUNTIME_CONFIG__ = { + apiBaseUrl: '', + appBaseUrl: '', + authDirectEnabled: 'false', + authDirectProvider: '', + authSessionBootstrapEnabled: 'false', + authSessionBootstrapProvider: '', + authSessionBootstrapAuto: 'false', +} diff --git a/web/runtime-config.js.template b/web/runtime-config.js.template new file mode 100644 index 00000000..1375a380 --- /dev/null +++ b/web/runtime-config.js.template @@ -0,0 +1,9 @@ +window.__SKILLHUB_RUNTIME_CONFIG__ = { + apiBaseUrl: "${SKILLHUB_WEB_API_BASE_URL}", + appBaseUrl: "${SKILLHUB_PUBLIC_BASE_URL}", + authDirectEnabled: "${SKILLHUB_WEB_AUTH_DIRECT_ENABLED}", + authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}", + authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}", + authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}", + authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}" +}; diff --git a/web/scripts/inject-docs.mjs b/web/scripts/inject-docs.mjs new file mode 100644 index 00000000..fc0d7a6d --- /dev/null +++ b/web/scripts/inject-docs.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { marked } from 'marked'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Paths +const projectRoot = join(__dirname, '../..'); +const docPath = join(projectRoot, 'docs/openclaw-integration-en.md'); +const distIndexPath = join(__dirname, '../dist/index.html'); + +console.log('📄 Reading markdown document...'); +const markdownContent = readFileSync(docPath, 'utf-8'); + +console.log('🔄 Converting markdown to HTML...'); +const htmlContent = marked.parse(markdownContent); + +console.log('📝 Reading dist/index.html...'); +const indexHtml = readFileSync(distIndexPath, 'utf-8'); + +// Create the hidden SEO container +const seoContainer = ` + + +`; + +// Inject before
+const injectedHtml = indexHtml.replace( + /
/, + `${seoContainer}\n
` +); + +console.log('💾 Writing updated index.html...'); +writeFileSync(distIndexPath, injectedHtml, 'utf-8'); + +console.log('✅ Documentation injected successfully!'); +console.log(` - Source: ${docPath}`); +console.log(` - Target: ${distIndexPath}`); +console.log(` - Content size: ~${Math.round(htmlContent.length / 1024)}KB`); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index df1e06c0..25e5dc35 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,24 +1,110 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' -import type { ApiToken, CreateTokenRequest, CreateTokenResponse, OAuthProvider, User } from './types' +import type { + ChangePasswordRequest, + ApiToken, + CreateTokenRequest, + CreateTokenResponse, + MergeConfirmRequest, + LocalLoginRequest, + LocalRegisterRequest, + MergeInitiateRequest, + MergeInitiateResponse, + MergeVerifyRequest, + ReviewTask, + PromotionTask, + AuditLogItem, + SkillSummary, + SkillReport, + GovernanceSummary, + GovernanceInboxItem, + GovernanceActivityItem, + GovernanceNotification, + ReportDisposition, + AuthMethod, + OAuthProvider, + User, + ManagedNamespace, + Namespace, + CreateNamespaceRequest, + NamespaceMember, + NamespaceCandidateUser, +} from './types' +import { ApiError } from '@/shared/lib/api-error' +import i18n from '@/i18n/config' -const client = createClient({ baseUrl: '' }) +export { ApiError } + +export const WEB_API_PREFIX = '/api/web' + +type RuntimeConfig = { + apiBaseUrl?: string + appBaseUrl?: string + authDirectEnabled?: string + authDirectProvider?: string + authSessionBootstrapEnabled?: string + authSessionBootstrapProvider?: string + authSessionBootstrapAuto?: string +} + +declare global { + interface Window { + __SKILLHUB_RUNTIME_CONFIG__?: RuntimeConfig + } +} + +function getRuntimeConfig(): RuntimeConfig { + if (typeof window === 'undefined') { + return {} + } + return window.__SKILLHUB_RUNTIME_CONFIG__ ?? {} +} + +function getApiBaseUrl(): string { + return getRuntimeConfig().apiBaseUrl ?? '' +} + +function parseBooleanFlag(value: string | undefined): boolean { + if (!value) { + return false + } + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()) +} + +const client = createClient({ baseUrl: getApiBaseUrl() }) function getCsrfToken(): string | null { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/) return match ? decodeURIComponent(match[1]) : null } +function withRequestHeaders(headers?: HeadersInit): Headers { + const merged = new Headers(headers) + const language = i18n.resolvedLanguage?.trim() + if (language) { + merged.set('Accept-Language', language) + } + return merged +} + function withCsrf(headers?: HeadersInit): HeadersInit { + const merged = withRequestHeaders(headers) const csrfToken = getCsrfToken() if (!csrfToken) { - return headers ?? {} + return merged } - return { - ...headers, - 'X-XSRF-TOKEN': csrfToken, + merged.set('X-XSRF-TOKEN', csrfToken) + return merged +} + +async function ensureCsrfHeaders(headers?: HeadersInit): Promise { + if (!getCsrfToken()) { + await client.GET('/api/v1/auth/providers', { + headers: withRequestHeaders(), + } as never) } + return withCsrf(headers) } function isApiEnvelope(value: unknown): value is ApiEnvelope { @@ -31,18 +117,20 @@ function hasDataProperty(value: unknown): value is { data: T } { async function unwrap(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise { const { data, error, response } = await promise - if (response.status === 401) { - throw new Error('HTTP 401') + const envelope = isApiEnvelope(data) ? data : isApiEnvelope(error) ? error : null + + if (!response.ok) { + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } if (error) { - throw new Error(`HTTP ${response.status}`) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } if (data === undefined) { - throw new Error(`HTTP ${response.status}`) + throw new ApiError(`HTTP ${response.status}`, response.status) } if (isApiEnvelope(data)) { if (data.code !== 0) { - throw new Error(data.msg || `HTTP ${response.status}`) + throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg, data.msg) } return data.data } @@ -56,6 +144,36 @@ export function getCsrfHeaders(headers?: HeadersInit): HeadersInit { return withCsrf(headers) } +export type SessionBootstrapRuntimeConfig = { + enabled: boolean + provider?: string + auto: boolean +} + +export type DirectAuthRuntimeConfig = { + enabled: boolean + provider?: string +} + +export function getDirectAuthRuntimeConfig(): DirectAuthRuntimeConfig { + const config = getRuntimeConfig() + const provider = config.authDirectProvider?.trim() + return { + enabled: parseBooleanFlag(config.authDirectEnabled) && !!provider, + provider: provider || undefined, + } +} + +export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfig { + const config = getRuntimeConfig() + const provider = config.authSessionBootstrapProvider?.trim() + return { + enabled: parseBooleanFlag(config.authSessionBootstrapEnabled) && !!provider, + provider: provider || undefined, + auto: parseBooleanFlag(config.authSessionBootstrapAuto), + } +} + type ApiEnvelope = { code: number msg: string @@ -64,39 +182,118 @@ type ApiEnvelope = { requestId: string } -export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init) +type RequestWithTimeout = RequestInit & { + timeoutMs?: number +} + +function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } { + if (!init?.timeoutMs && !init?.signal) { + return { signal: init?.signal ?? undefined, cleanup: () => {} } + } + + const controller = new AbortController() + const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined + const abortListener = () => controller.abort() + + if (init?.signal) { + if (init.signal.aborted) { + controller.abort() + } else { + init.signal.addEventListener('abort', abortListener, { once: true }) + } + } + + return { + signal: controller.signal, + cleanup: () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + init?.signal?.removeEventListener('abort', abortListener) + }, + } +} + +export async function fetchJson(input: RequestInfo | URL, init?: RequestWithTimeout): Promise { + const { signal, cleanup } = createRequestSignal(init) + let response: Response + try { + response = await fetch(withBaseUrl(input), { + ...init, + signal, + headers: withRequestHeaders(init?.headers), + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiError('error.request.timeout', 408) + } + throw new ApiError('Network error', 0) + } finally { + cleanup() + } + let json: ApiEnvelope | null = null try { json = (await response.json()) as ApiEnvelope } catch { if (!response.ok) { - throw new Error(`HTTP ${response.status}`) + throw new ApiError(`HTTP ${response.status}`, response.status) } - throw new Error('Invalid JSON response') + throw new ApiError('Invalid JSON response', response.status) } if (!response.ok || json.code !== 0) { - throw new Error(json.msg || `HTTP ${response.status}`) + throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg, json.msg) } return json.data } export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init) + const response = await fetch(withBaseUrl(input), { + ...init, + headers: withRequestHeaders(init?.headers), + }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.text() } +function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL { + const baseUrl = getApiBaseUrl() + if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) { + return input + } + return new URL(input, ensureTrailingSlash(baseUrl)) +} + +export function buildApiUrl(path: string): string { + const baseUrl = getApiBaseUrl() + if (!baseUrl) { + return path + } + return new URL(path, ensureTrailingSlash(baseUrl)).toString() +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : `${value}/` +} + export async function getCurrentUser(): Promise { try { - return await unwrap(client.GET('/api/v1/auth/me') as never) + const user = await unwrap(client.GET('/api/v1/auth/me', { + headers: withRequestHeaders(), + } as never) as never) + return { + ...user, + userId: user.userId ?? '', + displayName: user.displayName ?? '', + platformRoles: user.platformRoles ?? [], + } } catch (error) { - if (error instanceof Error && error.message === 'HTTP 401') { + if (error instanceof ApiError && error.status === 401) { return null } throw error @@ -106,45 +303,683 @@ export async function getCurrentUser(): Promise { export const authApi = { getMe: getCurrentUser, - async getProviders(): Promise { - return unwrap(client.GET('/api/v1/auth/providers') as never) + async getProviders(returnTo?: string): Promise { + const providers = await unwrap(client.GET('/api/v1/auth/providers', { + ...(returnTo ? { query: { returnTo } } : {}), + headers: withRequestHeaders(), + } as never) as never) + return providers + .filter((provider) => provider.id && provider.name && provider.authorizationUrl) + .map((provider) => ({ + ...provider, + id: provider.id!, + name: provider.name!, + authorizationUrl: provider.authorizationUrl!, + })) + }, + + async getMethods(returnTo?: string): Promise { + const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '' + const methods = await fetchJson(`/api/v1/auth/methods${query}`) + return methods + .filter((method) => method.id && method.methodType && method.provider && method.displayName && method.actionUrl) + .map((method) => ({ + ...method, + id: method.id, + methodType: method.methodType, + provider: method.provider, + displayName: method.displayName, + actionUrl: method.actionUrl, + })) + }, + + async localLogin(request: LocalLoginRequest): Promise { + return fetchJson('/api/v1/auth/local/login', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async localRegister(request: LocalRegisterRequest): Promise { + return fetchJson('/api/v1/auth/local/register', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async changePassword(request: ChangePasswordRequest): Promise { + await fetchJson('/api/v1/auth/local/change-password', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) }, async logout(): Promise { - const { response, error } = await client.POST('/api/v1/auth/logout', { + const response = await fetch('/api/v1/auth/logout', { + method: 'POST', headers: withCsrf(), }) - if (error || (response.status !== 200 && response.status !== 204)) { + if (response.status !== 200 && response.status !== 204) { throw new Error(`HTTP ${response.status}`) } }, + + async bootstrapSession(provider: string): Promise { + return fetchJson('/api/v1/auth/session/bootstrap', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ provider }), + }) + }, + + async directLogin(provider: string, request: LocalLoginRequest): Promise { + return fetchJson('/api/v1/auth/direct/login', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + provider, + username: request.username, + password: request.password, + }), + }) + }, +} + +export const accountApi = { + async initiateMerge(request: MergeInitiateRequest): Promise { + return fetchJson('/api/v1/account/merge/initiate', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async verifyMerge(request: MergeVerifyRequest): Promise { + await fetchJson('/api/v1/account/merge/verify', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async confirmMerge(request: MergeConfirmRequest): Promise { + await fetchJson('/api/v1/account/merge/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + +export const skillLifecycleApi = { + async archiveSkill(namespace: string, slug: string, reason?: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/archive`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}), + }) + }, + + async unarchiveSkill(namespace: string, slug: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/unarchive`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async deleteVersion(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async withdrawReview(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/withdraw-review`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/rerelease`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ targetVersion }), + }) + }, +} + +function normalizeNamespaceSlug(namespace: string): string { + return namespace.startsWith('@') ? namespace.slice(1) : namespace +} + +export const namespaceApi = { + async create(request: CreateNamespaceRequest): Promise { + const namespace = await unwrap(client.POST('/api/v1/namespaces', { + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: { + slug: normalizeNamespaceSlug(request.slug), + displayName: request.displayName.trim(), + description: request.description?.trim() || undefined, + }, + } as never) as never) + return namespace + }, + + async listMine(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) + }, + + async getDetail(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`) + }, + + async freeze(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async unfreeze(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async archive(slug: string, reason?: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/archive`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}), + }) + }, + + async restore(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listMembers(slug: string): Promise { + const page = await fetchJson<{ items: NamespaceMember[] }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`) + return page.items + }, + + async searchMemberCandidates(slug: string, search: string, size = 10): Promise { + const query = new URLSearchParams({ + search: search.trim(), + size: String(size), + }) + return fetchJson( + `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`, + ) + }, + + async addMember(slug: string, request: { userId: string; role: string }): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + userId: request.userId.trim(), + role: request.role, + }), + }) + }, + + async updateMemberRole(slug: string, userId: string, role: string): Promise { + return fetchJson( + `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`, + { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ role }), + }, + ) + }, + + async removeMember(slug: string, userId: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, } export const tokenApi = { - async getTokens(): Promise { - return unwrap(client.GET('/api/v1/tokens') as never) + async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> { + const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', { + params: { + query: { + page: params?.page ?? 0, + size: params?.size ?? 10, + }, + }, + headers: withRequestHeaders(), + } as never) as never) + return { + ...page, + items: page.items + .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) + .map((token) => ({ + ...token, + id: token.id!, + name: token.name!, + tokenPrefix: token.tokenPrefix!, + createdAt: token.createdAt!, + })), + } }, async createToken(request: CreateTokenRequest): Promise { - return unwrap(client.POST('/api/v1/tokens', { + const token = await unwrap(client.POST('/api/v1/tokens', { headers: withCsrf({ 'Content-Type': 'application/json', }), body: request, }) as never) + if (!token.token || token.id === undefined || !token.name || !token.tokenPrefix || !token.createdAt) { + throw new Error('Invalid token creation response') + } + return { + ...token, + token: token.token, + id: token.id, + name: token.name, + tokenPrefix: token.tokenPrefix, + createdAt: token.createdAt, + } + }, + + async updateTokenExpiration(tokenId: number, expiresAt?: string): Promise { + return fetchJson(`/api/v1/tokens/${tokenId}/expiration`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ expiresAt: expiresAt ?? '' }), + }) }, async deleteToken(tokenId: number): Promise { - const { response, error } = await client.DELETE('/api/v1/tokens/{id}', { + const { error, response } = await client.DELETE('/api/v1/tokens/{id}', { params: { path: { id: tokenId, }, }, headers: withCsrf(), + } as never) + + if (response.status === 204) { + return + } + + const envelope = (error && isApiEnvelope(error) ? error : null) as { msg?: string } | null + if (!response.ok || error) { + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) + } + }, +} + +export const reviewApi = { + async list(params: { status: string; namespaceId?: number; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status) + if (params.namespaceId !== undefined) { + searchParams.set('namespaceId', String(params.namespaceId)) + } + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/reviews?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/reviews/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/reviews/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), }) - if (error || response.status !== 204) { - throw new Error(`HTTP ${response.status}`) + }, + + async reject(id: number, comment: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/reviews/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const promotionApi = { + async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise { + await fetchJson(`${WEB_API_PREFIX}/promotions`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async list(params: { status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status ?? 'PENDING') + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/promotions?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotions/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/promotions/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment?: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/promotions/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const reportApi = { + async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/reports`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async listSkillReports(params: { status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status ?? 'PENDING') + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: SkillReport[]; total: number; page: number; size: number }>( + `/api/v1/admin/skill-reports?${searchParams.toString()}`, + ) + }, + + async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise { + await fetchJson(`/api/v1/admin/skill-reports/${id}/resolve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment, disposition }), + }) + }, + + async dismissSkillReport(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/admin/skill-reports/${id}/dismiss`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const governanceApi = { + async getSummary(): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/summary`) + }, + + async getInbox(params: { type?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.type) searchParams.set('type', params.type) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: GovernanceInboxItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/governance/inbox?${searchParams.toString()}`, + ) + }, + + async getActivity(params: { page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: GovernanceActivityItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`, + ) + }, + + async getNotifications(): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/notifications`) + }, + + async markNotificationRead(id: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, +} + +export const meApi = { + async getSkills(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params?.page ?? 0)) + searchParams.set('size', String(params?.size ?? 10)) + return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`) + }, + + async getStarsPage(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params?.page ?? 0)) + searchParams.set('size', String(params?.size ?? 12)) + return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/stars?${searchParams.toString()}`) + }, + + async getStars(): Promise { + const items: SkillSummary[] = [] + let page = 0 + const size = 100 + let hasMore = true + + while (hasMore) { + const response = await meApi.getStarsPage({ page, size }) + items.push(...response.items) + + hasMore = (page + 1) * response.size < response.total && response.items.length > 0 + page += 1 + } + + return items + }, +} + +export const adminApi = { + async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.search) searchParams.set('search', params.search) + if (params.status) searchParams.set('status', params.status) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + const response = await fetchJson<{ + items: Array<{ + id: string + username: string + email?: string + platformRoles?: string[] + status: string + createdAt: string + }> + total: number + page: number + size: number + }>( + `/api/v1/admin/users?${searchParams.toString()}`, + ) + return { + ...response, + items: response.items + .filter((user) => user.id && user.username && user.status && user.createdAt) + .map((user) => ({ + userId: user.id, + username: user.username, + email: user.email, + platformRoles: user.platformRoles ?? [], + status: user.status, + createdAt: user.createdAt, + })), } }, + + async updateUserRole(userId: string, role: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/role`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ role }), + }) + }, + + async updateUserStatus(userId: string, status: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/status`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ status }), + }) + }, + + async approveUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/approve`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async disableUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/disable`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async enableUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/enable`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async getAuditLogs(params: { + action?: string + userId?: string + requestId?: string + ipAddress?: string + resourceType?: string + resourceId?: string + startTime?: string + endTime?: string + page?: number + size?: number + }) { + const searchParams = new URLSearchParams() + if (params.action) searchParams.set('action', params.action) + if (params.userId) searchParams.set('userId', params.userId) + if (params.requestId) searchParams.set('requestId', params.requestId) + if (params.ipAddress) searchParams.set('ipAddress', params.ipAddress) + if (params.resourceType) searchParams.set('resourceType', params.resourceType) + if (params.resourceId) searchParams.set('resourceId', params.resourceId) + if (params.startTime) searchParams.set('startTime', params.startTime) + if (params.endTime) searchParams.set('endTime', params.endTime) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>( + `/api/v1/admin/audit-logs?${searchParams.toString()}`, + ) + }, + + async hideSkill(skillId: number, reason?: string): Promise { + await fetchJson(`/api/v1/admin/skills/${skillId}/hide`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, + + async unhideSkill(skillId: number): Promise { + await fetchJson(`/api/v1/admin/skills/${skillId}/unhide`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async yankVersion(versionId: number, reason?: string): Promise { + await fetchJson(`/api/v1/admin/skills/versions/${versionId}/yank`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 03c59c34..1799b873 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1,125 +1,3858 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + export interface paths { - '/api/v1/auth/me': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['User'] - } - } - 401: { - content?: never - } - } - } - } - '/api/v1/auth/providers': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_OAuthProviderList'] - } - } - } - } - } - '/api/v1/auth/logout': { - post: { - responses: { - 200: { - content?: never - } - 204: { - content?: never - } - } - } - } - '/api/v1/tokens': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_ApiTokenList'] - } - } - } - } - post: { - requestBody: { - content: { - 'application/json': components['schemas']['CreateTokenRequest'] - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_CreateTokenResponse'] - } - } - } - } - } - '/api/v1/tokens/{id}': { - delete: { - parameters: { - path: { - id: number - } - } - responses: { - 204: { - content?: never - } - } - } - } + "/api/v1/skills/{skillId}/star": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["checkStarred"]; + put: operations["starSkill"]; + post?: never; + delete: operations["unstarSkill"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{skillId}/rating": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUserRating"]; + put: operations["rateSkill"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["createOrMoveTag"]; + post?: never; + delete: operations["deleteTag"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getNamespace"]; + put: operations["updateNamespace"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateMemberRole"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUserStatus"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUserRole"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list"]; + put?: never; + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviews"]; + put?: never; + post: operations["submitReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/withdraw": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["withdrawReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rejectReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approveReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPromotions"]; + put?: never; + post: operations["submitPromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rejectPromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approvePromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listNamespaces"]; + put?: never; + post: operations["createNamespace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMembers"]; + put?: never; + post: operations["addMember"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/device/authorize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["authorizeDevice"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["check"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/device/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["pollToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/device/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestDeviceCode"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/change-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approveUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/{skillId}/unhide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["unhideSkill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/{skillId}/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["hideSkill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/versions/{versionId}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["yankVersion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/initiate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["initiate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirm"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSkillDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getVersionDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFiles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFileContent"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFilesByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFileContentByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolveVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadLatest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getReviewDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPendingReviews"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-submissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySubmissions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPromotionDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPendingPromotions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/stars": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyStars"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySkills"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["whoami"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resolve/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["providers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/audit-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAuditLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["whoami_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resolve/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/download/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["download"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/.well-known/clawhub.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["clawhubConfig"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeMember"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } - +export type webhooks = Record; export interface components { - schemas: { - User: { - userId: string - displayName: string - email: string - avatarUrl: string - oauthProvider: string - platformRoles: string[] - } - OAuthProvider: { - id: string - name: string - authorizationUrl: string - } - ApiToken: { - id: number - name: string - tokenPrefix: string - createdAt: string - expiresAt: string - lastUsedAt: string - } - CreateTokenRequest: { - name: string - scopes?: string[] - } - CreateTokenResponse: { - token: string - id: number - name: string - tokenPrefix: string - createdAt: string - expiresAt: string - } - ApiResponse_OAuthProviderList: { - data: components['schemas']['OAuthProvider'][] - } - ApiResponse_ApiTokenList: { - data: components['schemas']['ApiToken'][] - } - ApiResponse_CreateTokenResponse: { - data: components['schemas']['CreateTokenResponse'] - } - } + schemas: { + ApiResponseVoid: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: Record; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillRatingRequest: { + /** Format: int32 */ + score: number; + }; + TagRequest: { + tagName: string; + targetVersion: string; + }; + ApiResponseTagResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TagResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TagResponse: { + /** Format: int64 */ + id?: number; + tagName?: string; + /** Format: int64 */ + versionId?: number; + /** Format: date-time */ + createdAt?: string; + }; + NamespaceRequest: { + slug: string; + displayName: string; + description?: string; + }; + ApiResponseNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["NamespaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + NamespaceResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + /** @enum {string} */ + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + description?: string; + /** @enum {string} */ + type?: "GLOBAL" | "TEAM"; + avatarUrl?: string; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + UpdateMemberRoleRequest: { + /** @enum {string} */ + role: "OWNER" | "ADMIN" | "MEMBER"; + }; + ApiResponseMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MemberResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + namespaceId?: number; + userId?: string; + /** @enum {string} */ + role?: "OWNER" | "ADMIN" | "MEMBER"; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + AdminUserStatusUpdateRequest: { + status: string; + }; + AdminUserMutationResponse: { + userId?: string; + role?: string; + status?: string; + }; + ApiResponseAdminUserMutationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminUserMutationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AdminUserRoleUpdateRequest: { + role: string; + }; + TokenCreateRequest: { + name: string; + scopes?: string[]; + }; + ApiResponseTokenCreateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenCreateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateResponse: { + token?: string; + /** Format: int64 */ + id?: number; + name?: string; + tokenPrefix?: string; + createdAt?: string; + expiresAt?: string; + }; + ApiResponsePublishResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PublishResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PublishResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + status?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + }; + ReviewTaskRequest: { + /** Format: int64 */ + skillVersionId?: number; + }; + ApiResponseReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewTaskResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ReviewTaskResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + skillVersionId?: number; + namespace?: string; + skillSlug?: string; + version?: string; + status?: string; + submittedBy?: string; + submittedByName?: string; + reviewedBy?: string; + reviewedByName?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + ReviewActionRequest: { + comment?: string; + }; + PromotionRequestDto: { + /** Format: int64 */ + sourceSkillId?: number; + /** Format: int64 */ + sourceVersionId?: number; + /** Format: int64 */ + targetNamespaceId?: number; + }; + ApiResponsePromotionResponseDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PromotionResponseDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PromotionResponseDto: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + sourceSkillId?: number; + sourceNamespace?: string; + sourceSkillSlug?: string; + sourceVersion?: string; + targetNamespace?: string; + /** Format: int64 */ + targetSkillId?: number; + status?: string; + submittedBy?: string; + submittedByName?: string; + reviewedBy?: string; + reviewedByName?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + PromotionActionRequest: { + comment?: string; + }; + MemberRequest: { + userId: string; + /** @enum {string} */ + role: "OWNER" | "ADMIN" | "MEMBER"; + }; + AuthorizeRequest: { + userCode?: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; + ApiResponseSkillCheckResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillCheckResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillCheckResponse: { + valid?: boolean; + errors?: string[]; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + }; + TokenRequest: { + deviceCode?: string; + }; + ApiResponseDeviceTokenResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DeviceTokenResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DeviceTokenResponse: { + accessToken?: string; + tokenType?: string; + error?: string; + }; + ApiResponseDeviceCodeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DeviceCodeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DeviceCodeResponse: { + deviceCode?: string; + userCode?: string; + verificationUri?: string; + /** Format: int32 */ + expiresIn?: number; + /** Format: int32 */ + interval?: number; + }; + LocalRegisterRequest: { + username: string; + password: string; + email?: string; + }; + ApiResponseAuthMeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AuthMeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuthMeResponse: { + userId?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + oauthProvider?: string; + platformRoles?: string[]; + }; + LocalLoginRequest: { + username: string; + password: string; + }; + ChangePasswordRequest: { + currentPassword: string; + newPassword: string; + }; + AdminSkillMutationResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + versionId?: number; + action?: string; + status?: string; + }; + ApiResponseAdminSkillMutationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminSkillMutationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AdminSkillActionRequest: { + reason?: string; + }; + MergeVerifyRequest: { + /** Format: int64 */ + mergeRequestId: number; + verificationToken: string; + }; + MergeInitiateRequest: { + secondaryIdentifier: string; + }; + ApiResponseMergeInitiateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MergeInitiateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MergeInitiateResponse: { + /** Format: int64 */ + mergeRequestId?: number; + secondaryUserId?: string; + verificationToken?: string; + expiresAt?: string; + }; + ConfirmMergeRequest: { + /** Format: int64 */ + mergeRequestId: number; + }; + ClawHubPublishResponse: { + canonicalSlug?: string; + version?: string; + status?: string; + }; + ApiResponseListTokenSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenSummaryResponse: { + /** Format: int64 */ + id?: number; + name?: string; + tokenPrefix?: string; + createdAt?: string; + expiresAt?: string; + lastUsedAt?: string; + }; + ApiResponseSearchResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SearchResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SearchResponse: { + items?: components["schemas"]["SkillSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillSummaryResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + summary?: string; + status?: string; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + namespace?: string; + /** Format: date-time */ + updatedAt?: string; + canSubmitPromotion?: boolean; + headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; + publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; + ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; + resolutionMode?: string; + }; + ApiResponseBoolean: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: boolean; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseSkillRatingStatusResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillRatingStatusResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillRatingStatusResponse: { + /** Format: int32 */ + score?: number; + rated?: boolean; + }; + ApiResponseSkillDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillDetailResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + summary?: string; + visibility?: string; + status?: string; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + hidden?: boolean; + namespace?: string; + canManageLifecycle?: boolean; + canSubmitPromotion?: boolean; + canInteract?: boolean; + canReport?: boolean; + headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; + publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; + ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; + resolutionMode?: string; + }; + SkillLifecycleVersionResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + }; + ApiResponsePageResponseSkillVersionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseSkillVersionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseSkillVersionResponse: { + items?: components["schemas"]["SkillVersionResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillVersionResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + changelog?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + /** Format: date-time */ + publishedAt?: string; + }; + ApiResponseSkillVersionDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillVersionDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillVersionDetailResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + changelog?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + /** Format: date-time */ + publishedAt?: string; + parsedMetadataJson?: string; + manifestJson?: string; + }; + ApiResponseListSkillFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillFileResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillFileResponse: { + /** Format: int64 */ + id?: number; + filePath?: string; + /** Format: int64 */ + fileSize?: number; + contentType?: string; + sha256?: string; + }; + ApiResponseListTagResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TagResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseResolveVersionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ResolveVersionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ResolveVersionResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + /** Format: int64 */ + versionId?: number; + fingerprint?: string; + matched?: boolean; + downloadUrl?: string; + }; + ApiResponsePageResponseReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseReviewTaskResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseReviewTaskResponse: { + items?: components["schemas"]["ReviewTaskResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponsePromotionResponseDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponsePromotionResponseDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponsePromotionResponseDto: { + items?: components["schemas"]["PromotionResponseDto"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + Pageable: { + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + sort?: string[]; + }; + ApiResponsePageResponseNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseNamespaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseNamespaceResponse: { + items?: components["schemas"]["NamespaceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponseMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseMemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseMemberResponse: { + items?: components["schemas"]["MemberResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponseListSkillSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseCliWhoamiResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["CliWhoamiResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + CliWhoamiResponse: { + userId?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + authType?: string; + platformRoles?: string[]; + }; + ApiResponseListAuthProviderResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AuthProviderResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuthProviderResponse: { + id?: string; + name?: string; + authorizationUrl?: string; + }; + AdminUserSummaryResponse: { + userId?: string; + username?: string; + email?: string; + platformRoles?: string[]; + status?: string; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponsePageResponseAdminUserSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseAdminUserSummaryResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseAdminUserSummaryResponse: { + items?: components["schemas"]["AdminUserSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponseAuditLogItemResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseAuditLogItemResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuditLogItemResponse: { + id?: string; + userId?: string; + action?: string; + resourceType?: string; + resourceId?: string; + /** Format: date-time */ + timestamp?: string; + ipAddress?: string; + }; + PageResponseAuditLogItemResponse: { + items?: components["schemas"]["AuditLogItemResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ClawHubWhoamiResponse: { + userId?: string; + displayName?: string; + email?: string; + }; + ClawHubSearchResponse: { + items?: components["schemas"]["ClawHubSkillItem"][]; + }; + ClawHubSkillItem: { + canonicalSlug?: string; + description?: string; + latestVersion?: string; + /** Format: int32 */ + starCount?: number; + }; + ClawHubResolveResponse: { + canonicalSlug?: string; + version?: string; + downloadUrl?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + checkStarred: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBoolean"]; + }; + }; + }; + }; + starSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + unstarSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + getUserRating: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillRatingStatusResponse"]; + }; + }; + }; + }; + rateSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillRatingRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + createOrMoveTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TagRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseTagResponse"]; + }; + }; + }; + }; + deleteTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + getNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + updateNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + updateMemberRole: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + updateUserStatus: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateUserRole: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListTokenSummaryResponse"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; + }; + }; + }; + }; + publish: { + parameters: { + query: { + visibility: string; + }; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePublishResponse"]; + }; + }; + }; + }; + listReviews: { + parameters: { + query: { + status: string; + namespaceId?: number; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + submitReview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReviewTaskRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + withdrawReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + rejectReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReviewActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + approveReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReviewActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + listPromotions: { + parameters: { + query?: { + status?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + }; + }; + }; + }; + submitPromotion: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PromotionRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + rejectPromotion: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + approvePromotion: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + listNamespaces: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseNamespaceResponse"]; + }; + }; + }; + }; + createNamespace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + listMembers: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + }; + }; + }; + }; + addMember: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + authorizeDevice: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthorizeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_1: { + parameters: { + query: { + namespace: string; + visibility: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePublishResponse"]; + }; + }; + }; + }; + check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillCheckResponse"]; + }; + }; + }; + }; + pollToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; + }; + }; + }; + }; + requestDeviceCode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; + }; + }; + }; + }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalRegisterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalLoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePasswordRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + enableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + disableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + approveUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + unhideSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + hideSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + yankVersion: { + parameters: { + query?: never; + header?: never; + path: { + versionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + verify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeVerifyRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + initiate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeInitiateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + }; + }; + }; + }; + confirm: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_2: { + parameters: { + query: { + namespace: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubPublishResponse"]; + }; + }; + }; + }; + search: { + parameters: { + query?: { + q?: string; + namespace?: string; + sort?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSearchResponse"]; + }; + }; + }; + }; + getSkillDetail: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + }; + }; + }; + }; + listVersions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + }; + }; + }; + }; + getVersionDetail: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + }; + }; + }; + }; + listFiles: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + }; + }; + }; + }; + getFileContent: { + parameters: { + query: { + path: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadVersion: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + listTags: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListTagResponse"]; + }; + }; + }; + }; + listFilesByTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + }; + }; + }; + }; + getFileContentByTag: { + parameters: { + query: { + path: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadByTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + resolveVersion: { + parameters: { + query?: { + version?: string; + tag?: string; + hash?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + }; + }; + }; + }; + downloadLatest: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + getReviewDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + listPendingReviews: { + parameters: { + query: { + namespaceId: number; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + listMySubmissions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + getPromotionDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + listPendingPromotions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + }; + }; + }; + }; + listMyStars: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSummaryResponse"]; + }; + }; + }; + }; + listMySkills: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSummaryResponse"]; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + whoami: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliWhoamiResponse"]; + }; + }; + }; + }; + resolve: { + parameters: { + query?: { + version?: string; + tag?: string; + hash?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + }; + }; + }; + }; + providers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListAuthProviderResponse"]; + }; + }; + }; + }; + me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + listUsers: { + parameters: { + query?: { + search?: string; + status?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseAdminUserSummaryResponse"]; + }; + }; + }; + }; + listAuditLogs: { + parameters: { + query?: { + page?: number; + size?: number; + userId?: string; + action?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseAuditLogItemResponse"]; + }; + }; + }; + }; + whoami_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubWhoamiResponse"]; + }; + }; + }; + }; + search_1: { + parameters: { + query: { + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubSearchResponse"]; + }; + }; + }; + }; + resolve_1: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + canonicalSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubResolveResponse"]; + }; + }; + }; + }; + download: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + canonicalSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + clawhubConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; + revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + removeMember: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b3dcdc65..82860131 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,12 +1,96 @@ import type { components } from './generated/schema' -export type User = components['schemas']['User'] -export type OAuthProvider = components['schemas']['OAuthProvider'] -export type ApiToken = components['schemas']['ApiToken'] -export type CreateTokenRequest = components['schemas']['CreateTokenRequest'] -export type CreateTokenResponse = components['schemas']['CreateTokenResponse'] +export type User = Omit & { + userId: string + displayName: string + email?: string + avatarUrl?: string + oauthProvider?: string + platformRoles: string[] +} + +export type OAuthProvider = Omit & { + id: string + name: string + authorizationUrl: string +} + +export interface AuthMethod { + id: string + methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string + provider: string + displayName: string + actionUrl: string +} + +export type ApiToken = Omit & { + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string + lastUsedAt?: string +} + +export type CreateTokenRequest = Omit & { + name: string + scopes?: string[] + expiresAt?: string +} + +export type CreateTokenResponse = Omit & { + token: string + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string +} + +export interface LocalLoginRequest { + username: string + password: string +} + +export interface LocalRegisterRequest extends LocalLoginRequest { + email?: string +} + +export interface ChangePasswordRequest { + currentPassword: string + newPassword: string +} + +export type CreateNamespaceRequest = Omit & { + slug: string + displayName: string + description?: string +} + +export interface MergeInitiateRequest { + secondaryIdentifier: string +} + +export interface MergeInitiateResponse { + mergeRequestId: number + secondaryUserId: string + verificationToken: string + expiresAt: string +} + +export interface MergeVerifyRequest { + mergeRequestId: number + verificationToken: string +} + +export interface MergeConfirmRequest { + mergeRequestId: number +} // Namespace types +export type NamespaceStatus = 'ACTIVE' | 'FROZEN' | 'ARCHIVED' | string +export type NamespaceRole = 'OWNER' | 'ADMIN' | 'MEMBER' | string + export interface Namespace { id: number slug: string @@ -14,31 +98,59 @@ export interface Namespace { description?: string type: 'GLOBAL' | 'TEAM' avatarUrl?: string - status: string + status: NamespaceStatus createdAt: string updatedAt?: string } +export interface ManagedNamespace extends Namespace { + createdBy?: string + currentUserRole?: NamespaceRole + immutable: boolean + canFreeze: boolean + canUnfreeze: boolean + canArchive: boolean + canRestore: boolean +} + export interface NamespaceMember { id: number userId: string - role: string + role: NamespaceRole createdAt: string } +export interface NamespaceCandidateUser { + userId: string + displayName: string + email?: string + status: string +} + // Skill types export interface SkillSummary { id: number slug: string displayName: string summary?: string + status?: string downloadCount: number starCount: number ratingAvg?: number ratingCount: number - latestVersion?: string namespace: string updatedAt: string + canSubmitPromotion: boolean + headlineVersion?: SkillLifecycleVersion + publishedVersion?: SkillLifecycleVersion + ownerPreviewVersion?: SkillLifecycleVersion + resolutionMode?: string +} + +export interface SkillLifecycleVersion { + id: number + version: string + status: string } export interface SkillDetail { @@ -50,8 +162,24 @@ export interface SkillDetail { status: string downloadCount: number starCount: number - latestVersion?: string + ratingAvg?: number + ratingCount: number + hidden: boolean namespace: string + canManageLifecycle: boolean + canSubmitPromotion: boolean + canInteract: boolean + canReport: boolean + headlineVersion?: SkillLifecycleVersion + publishedVersion?: SkillLifecycleVersion + ownerPreviewVersion?: SkillLifecycleVersion + resolutionMode?: string +} + +export interface SubmitPromotionRequest { + sourceSkillId: number + sourceVersionId: number + targetNamespaceId: number } export interface SkillVersion { @@ -62,6 +190,19 @@ export interface SkillVersion { fileCount: number totalSize: number publishedAt: string + downloadAvailable: boolean +} + +export interface SkillVersionDetail { + id: number + version: string + status: string + changelog?: string + fileCount: number + totalSize: number + publishedAt: string + parsedMetadataJson?: string + manifestJson?: string } export interface SkillFile { @@ -86,6 +227,7 @@ export interface SearchParams { sort?: string page?: number size?: number + starredOnly?: boolean } export interface PagedResponse { @@ -105,3 +247,116 @@ export interface PublishResult { fileCount: number totalSize: number } + +export interface ReviewTask { + id: number + skillVersionId: number + namespace: string + skillSlug: string + version: string + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedByName?: string + reviewedBy?: string + reviewedByName?: string + reviewComment?: string + submittedAt: string + reviewedAt?: string +} + +export interface PromotionTask { + id: number + sourceSkillId: number + sourceNamespace: string + sourceSkillSlug: string + sourceVersion: string + targetNamespace: string + targetSkillId?: number + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedByName?: string + reviewedBy?: string + reviewedByName?: string + reviewComment?: string + submittedAt: string + reviewedAt?: string +} + +export interface SkillReport { + id: number + skillId: number + namespace?: string + skillSlug?: string + skillDisplayName?: string + reporterId: string + reason: string + details?: string + status: 'PENDING' | 'RESOLVED' | 'DISMISSED' | string + handledBy?: string + handleComment?: string + createdAt: string + handledAt?: string +} + +export type ReportDisposition = 'RESOLVE_ONLY' | 'RESOLVE_AND_HIDE' | 'RESOLVE_AND_ARCHIVE' + +export interface GovernanceSummary { + pendingReviews: number + pendingPromotions: number + pendingReports: number +} + +export interface GovernanceInboxItem { + type: 'REVIEW' | 'PROMOTION' | 'REPORT' | string + id: number + title: string + subtitle?: string + timestamp?: string + namespace?: string + skillSlug?: string +} + +export interface GovernanceActivityItem { + id: number + action: string + actorUserId?: string + actorDisplayName?: string + targetType?: string + targetId?: string + details?: string + timestamp?: string +} + +export interface GovernanceNotification { + id?: number + category: string + entityType: string + entityId: number + title: string + bodyJson?: string + status: 'UNREAD' | 'READ' | string + createdAt?: string + readAt?: string +} + +export interface AdminUser { + userId: string + username: string + email?: string + platformRoles: string[] + status: string + createdAt: string +} + +export interface AuditLogItem { + id: string + userId?: string + username?: string + action: string + details?: string + requestId?: string + resourceType?: string + resourceId?: string + timestamp: string + ipAddress?: string +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5685be3f..2f25b34e 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,45 +1,187 @@ -import { Outlet, Link } from '@tanstack/react-router' +import { Suspense } from 'react' +import { Outlet, Link, useRouterState } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' +import { LanguageSwitcher } from '@/shared/components/language-switcher' +import { UserMenu } from '@/shared/components/user-menu' export function Layout() { + const { t } = useTranslation() + const pathname = useRouterState({ select: (s) => s.location.pathname }) const { user, isLoading } = useAuth() + const navItems: Array<{ + label: string + to: string + exact?: boolean + auth?: boolean + }> = [ + { 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.dashboard'), to: '/dashboard', auth: true }, + { label: t('nav.mySkills'), to: '/dashboard/skills', auth: true }, + ] + + const isActive = (to: string, exact?: boolean) => { + if (exact) return pathname === to + // 精确匹配,避免父路径也被高亮 + return pathname === to + } + return ( -
-
-
- - SkillHub - - + +
+ + {isLoading ? null : user ? ( + + ) : ( + + {t('nav.login')} + + )}
-
- + + {/* Main content */} +
+ +
+
+
+
+ } + > + +
+ + {/* Footer */} +
+
+
+
+
+
+ S +
+ SkillHub +
+

+ {t('layout.footerDescription')} +

+
+
+
+

+ {t('nav.home')} +

+
    +
  • + + {t('nav.home')} + +
  • +
  • + + {t('nav.search')} + +
  • +
  • + + {t('nav.dashboard')} + +
  • +
+
+
+

+ {t('footer.resources')} +

+ +
+
+
+
+ {t('footer.copyright')} +
+ + {t('footer.privacy')} + + | + + {t('footer.terms')} + +
+
+
+
) } diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 1661d244..37880816 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -1,21 +1,49 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query' import { RouterProvider } from '@tanstack/react-router' +import { Toaster } from '@/shared/components/toaster' +import { handleApiError } from '@/shared/lib/api-error' import { router } from './router' const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 分钟 - retry: 1, + staleTime: 30 * 1000, + retry: (failureCount, error) => { + // Don't retry on 401/403/404 + if (error instanceof Error && /HTTP (401|403|404)/.test(error.message)) { + return false + } + return failureCount < 1 + }, refetchOnWindowFocus: false, }, }, + queryCache: new QueryCache({ + onError: (error, query) => { + if (query.meta?.skipGlobalErrorHandler) { + return + } + handleApiError(error) + }, + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + if (mutation.meta?.skipGlobalErrorHandler) { + return + } + // Only auto-handle if the mutation doesn't have its own onError + if (!mutation.options.onError) { + handleApiError(error) + } + }, + }), }) export function App() { return ( + ) } diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ff0e6f94..f70f0e1b 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -2,9 +2,23 @@ import { lazy, Suspense, type ComponentType } from 'react' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' import { Layout } from './layout' import { getCurrentUser } from '@/api/client' +import { RoleGuard } from '@/shared/components/role-guard' +import { normalizeSearchQuery } from '@/shared/lib/search-query' -function createLazyRouteComponent(load: () => Promise<{ default: ComponentType }>) { - const LazyComponent = lazy(load) +// Capture original URL before TanStack Router rewrites it +const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' + +// Export for use in cli-auth page +export { ORIGINAL_URL_SEARCH } + +function createLazyRouteComponent>( + importer: () => Promise, + exportName: keyof TModule, +) { + const LazyComponent = lazy(async () => { + const module = await importer() + return { default: module[exportName] as ComponentType } + }) return function LazyRouteComponent(props: Record) { return ( @@ -21,226 +35,329 @@ function createLazyRouteComponent(load: () => Promise<{ default: ComponentType - import('@/pages/home').then((module) => ({ default: module.HomePage })), -) -const LoginPage = createLazyRouteComponent(() => - import('@/pages/login').then((module) => ({ default: module.LoginPage })), -) -const DashboardPage = createLazyRouteComponent(() => - import('@/pages/dashboard').then((module) => ({ default: module.DashboardPage })), -) -const SearchPage = createLazyRouteComponent(() => - import('@/pages/search').then((module) => ({ default: module.SearchPage })), -) -const NamespacePage = createLazyRouteComponent(() => - import('@/pages/namespace').then((module) => ({ default: module.NamespacePage })), -) -const SkillDetailPage = createLazyRouteComponent(() => - import('@/pages/skill-detail').then((module) => ({ default: module.SkillDetailPage })), +function createRoleProtectedRouteComponent>( + importer: () => Promise, + exportName: keyof TModule, + allowedRoles: readonly string[], +) { + const RouteComponent = createLazyRouteComponent(importer, exportName) + + return function RoleProtectedRouteComponent(props: Record) { + return ( + + + + ) + } +} + +const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'LandingPage') +const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage') +const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage') +const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') +const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage') +const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage') +const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') +const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') +const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') +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 MyNamespacesPage = createLazyRouteComponent( + () => import('@/pages/dashboard/my-namespaces'), + 'MyNamespacesPage', ) -const PublishPage = createLazyRouteComponent(() => - import('@/pages/dashboard/publish').then((module) => ({ default: module.PublishPage })), +const NamespaceMembersPage = createLazyRouteComponent( + () => import('@/pages/dashboard/namespace-members'), + 'NamespaceMembersPage', ) -const MySkillsPage = createLazyRouteComponent(() => - import('@/pages/dashboard/my-skills').then((module) => ({ default: module.MySkillsPage })), +const NamespaceReviewsPage = createLazyRouteComponent( + () => import('@/pages/dashboard/namespace-reviews'), + 'NamespaceReviewsPage', ) -const MyNamespacesPage = createLazyRouteComponent(() => - import('@/pages/dashboard/my-namespaces').then((module) => ({ default: module.MyNamespacesPage })), +const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') +const ReviewsPage = createRoleProtectedRouteComponent( + () => import('@/pages/dashboard/reviews'), + 'ReviewsPage', + ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'], ) -const NamespaceMembersPage = createLazyRouteComponent(() => - import('@/pages/dashboard/namespace-members').then((module) => ({ default: module.NamespaceMembersPage })), +const ReportsPage = createRoleProtectedRouteComponent( + () => import('@/pages/dashboard/reports'), + 'ReportsPage', + ['SKILL_ADMIN', 'SUPER_ADMIN'], ) -const ReviewsPage = createLazyRouteComponent(() => - import('@/pages/dashboard/reviews').then((module) => ({ default: module.ReviewsPage })), +const ReviewDetailPage = createRoleProtectedRouteComponent( + () => import('@/pages/dashboard/review-detail'), + 'ReviewDetailPage', + ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'], ) -const ReviewDetailPage = createLazyRouteComponent(() => - import('@/pages/dashboard/review-detail').then((module) => ({ default: module.ReviewDetailPage })), +const PromotionsPage = createRoleProtectedRouteComponent( + () => import('@/pages/dashboard/promotions'), + 'PromotionsPage', + ['SKILL_ADMIN', 'SUPER_ADMIN'], ) -const DeviceAuthPage = createLazyRouteComponent(() => - import('@/pages/device').then((module) => ({ default: module.DeviceAuthPage })), +const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') +const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') +const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') +const SecuritySettingsPage = createLazyRouteComponent( + () => import('@/pages/settings/security'), + 'SecuritySettingsPage', ) -const AdminUsersPage = createLazyRouteComponent(() => - import('@/pages/admin/users').then((module) => ({ default: module.AdminUsersPage })), +const AdminUsersPage = createRoleProtectedRouteComponent( + () => import('@/pages/admin/users'), + 'AdminUsersPage', + ['USER_ADMIN', 'SUPER_ADMIN'], ) -const AuditLogPage = createLazyRouteComponent(() => - import('@/pages/admin/audit-log').then((module) => ({ default: module.AuditLogPage })), +const AuditLogPage = createRoleProtectedRouteComponent( + () => import('@/pages/admin/audit-log'), + 'AuditLogPage', + ['AUDITOR', 'SUPER_ADMIN'], ) +function DefaultNotFound() { + return ( +
+ Not Found +
+ ) +} + const rootRoute = createRootRoute({ component: Layout, + notFoundComponent: DefaultNotFound, }) -const homeRoute = createRoute({ +function buildReturnTo(location: { pathname: string; searchStr?: string; hash?: string }) { + return `${location.pathname}${location.searchStr ?? ''}${location.hash ?? ''}` +} + +async function requireAuth({ location }: { location: { pathname: string; searchStr?: string; hash?: string } }) { + const user = await getCurrentUser() + if (!user) { + throw redirect({ + to: '/login', + search: { returnTo: buildReturnTo(location) }, + }) + } + return { user } +} + +const landingRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', + component: LandingPage, +}) + +const skillsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'skills', component: HomePage, }) const loginRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/login', + path: 'login', + validateSearch: (search: Record): { returnTo: string; reason?: string } => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + reason: typeof search.reason === 'string' ? search.reason : undefined, + }), component: LoginPage, }) +const registerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'register', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), + component: RegisterPage, +}) + +const privacyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'privacy', + component: PrivacyPolicyPage, +}) + const searchRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/search', + path: 'search', component: SearchPage, validateSearch: (search: Record) => { return { - q: (search.q as string) || '', - sort: (search.sort as string) || 'relevance', - page: Number(search.page) || 1, + q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), + sort: (search.sort as string) || 'newest', + page: Number(search.page) || 0, + starredOnly: search.starredOnly === true || search.starredOnly === 'true', } }, }) +const termsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'terms', + component: TermsOfServicePage, +}) + const namespaceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace', + path: '/space/$namespace', component: NamespacePage, }) const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace/$slug', + path: '/space/$namespace/$slug', + validateSearch: (search: Record): { returnTo?: string } => ({ + returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, + }), component: SkillDetailPage, }) const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard', + beforeLoad: requireAuth, component: DashboardPage, }) const dashboardSkillsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/skills', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/skills', + beforeLoad: requireAuth, component: MySkillsPage, }) const dashboardPublishRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/publish', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/publish', + beforeLoad: requireAuth, component: PublishPage, }) const dashboardNamespacesRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces', + beforeLoad: requireAuth, component: MyNamespacesPage, }) const dashboardNamespaceMembersRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces/$slug/members', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces/$slug/members', + beforeLoad: requireAuth, component: NamespaceMembersPage, }) +const dashboardNamespaceReviewsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug/reviews', + beforeLoad: requireAuth, + component: NamespaceReviewsPage, +}) + +const dashboardGovernanceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/governance', + beforeLoad: requireAuth, + component: GovernancePage, +}) + const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/reviews', + beforeLoad: requireAuth, component: ReviewsPage, }) +const dashboardReportsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/reports', + beforeLoad: requireAuth, + component: ReportsPage, +}) + const dashboardReviewDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews/$id', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/reviews/$id', + beforeLoad: requireAuth, component: ReviewDetailPage, }) -const deviceRoute = createRoute({ +const dashboardPromotionsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/device', - component: DeviceAuthPage, + path: 'dashboard/promotions', + beforeLoad: requireAuth, + component: PromotionsPage, }) -const adminUsersRoute = createRoute({ +const dashboardStarsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/users', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { - throw redirect({ to: '/dashboard' }) + path: 'dashboard/stars', + beforeLoad: requireAuth, + component: MyStarsPage, +}) + +const dashboardTokensRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/tokens', + beforeLoad: requireAuth, + component: TokensPage, +}) + +const cliAuthRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'cli/auth', + component: CliAuthPage, + validateSearch: (search: Record): Record => { + // Preserve all CLI auth parameters - use empty string instead of undefined to prevent TanStack Router from removing them + return { + redirect_uri: typeof search.redirect_uri === 'string' ? search.redirect_uri : '', + label_b64: typeof search.label_b64 === 'string' ? search.label_b64 : '', + label: typeof search.label === 'string' ? search.label : '', + state: typeof search.state === 'string' ? search.state : '', } - return { user } }, +}) + +const settingsSecurityRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'settings/security', + beforeLoad: requireAuth, + component: SecuritySettingsPage, +}) + +const settingsAccountsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'settings/accounts', + beforeLoad: async (ctx) => { + await requireAuth(ctx) + throw redirect({ to: '/settings/security' }) + }, +}) + +const adminUsersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'admin/users', + beforeLoad: requireAuth, component: AdminUsersPage, }) const adminAuditLogRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/audit-log', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) { - throw redirect({ to: '/dashboard' }) - } - return { user } - }, + path: 'admin/audit-log', + beforeLoad: requireAuth, component: AuditLogPage, }) const routeTree = rootRoute.addChildren([ - homeRoute, + landingRoute, + skillsRoute, loginRoute, + registerRoute, + privacyRoute, searchRoute, + termsRoute, namespaceRoute, skillDetailRoute, dashboardRoute, @@ -248,14 +365,25 @@ const routeTree = rootRoute.addChildren([ dashboardPublishRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, + dashboardNamespaceReviewsRoute, + dashboardGovernanceRoute, dashboardReviewsRoute, + dashboardReportsRoute, dashboardReviewDetailRoute, - deviceRoute, + dashboardPromotionsRoute, + dashboardStarsRoute, + dashboardTokensRoute, + cliAuthRoute, + settingsSecurityRoute, + settingsAccountsRoute, adminUsersRoute, adminAuditLogRoute, ]) -export const router = createRouter({ routeTree }) +export const router = createRouter({ + routeTree, + defaultNotFoundComponent: DefaultNotFound, +}) declare module '@tanstack/react-router' { interface Register { diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts new file mode 100644 index 00000000..1e1e04d2 --- /dev/null +++ b/web/src/bootstrap.ts @@ -0,0 +1,33 @@ +async function loadRuntimeConfig() { + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = '/runtime-config.js' + script.async = false + script.onload = () => resolve() + script.onerror = () => reject(new Error('Failed to load runtime config')) + document.head.appendChild(script) + }) +} + +function ensureRuntimeConfigFallback() { + window.__SKILLHUB_RUNTIME_CONFIG__ ??= { + apiBaseUrl: '', + appBaseUrl: '', + authDirectEnabled: 'false', + authDirectProvider: '', + authSessionBootstrapEnabled: 'false', + authSessionBootstrapProvider: '', + authSessionBootstrapAuto: 'false', + } +} + +void (async () => { + try { + await loadRuntimeConfig() + } catch (error) { + console.error(error) + ensureRuntimeConfigFallback() + } + + await import('./main') +})() diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md new file mode 100644 index 00000000..ac1bf910 --- /dev/null +++ b/web/src/docs/skill.md @@ -0,0 +1,193 @@ +--- +name: skillhub-registry +description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a self-hosted skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. +--- + +# SkillHub Registry + +Use this skill when you need to work with a SkillHub deployment: search skills, inspect metadata, install a package, or publish a new version. + +> Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. + +## What SkillHub Is + +SkillHub is a self-hosted, enterprise-oriented skill registry. It stores versioned skill packages, supports namespace-based governance, and keeps `SKILL.md` compatibility with OpenSkills-style packages. + +Key facts: + +- Internal coordinates use `@{namespace}/{skill_slug}`. +- ClawHub-compatible clients use a canonical slug instead. +- `latest` always means the latest published version, never draft or pending review. +- Public skills in `@global` can be downloaded anonymously. +- Team namespace skills and non-public skills require authentication. + +## Configure The CLI + +Point `clawhub` at the SkillHub base URL: + +```bash +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +``` + +If you need authenticated access, provide an API token: + +```bash +export CLAWHUB_API_TOKEN=sk_your_api_token_here +``` + +Optional local check: + +```bash +curl https://skillhub.your-company.com/.well-known/clawhub.json +``` + +Expected response: + +```json +{ "apiBase": "/api/v1" } +``` + +## Coordinate Rules + +SkillHub has two naming forms: + +| SkillHub coordinate | Canonical slug for `clawhub` | +|---|---| +| `@global/my-skill` | `my-skill` | +| `@team-name/my-skill` | `team-name--my-skill` | + +Rules: + +- `--` is the namespace separator in the compatibility layer. +- If there is no `--`, the skill is treated as `@global/...`. +- `latest` resolves to the latest published version only. + +Examples: + +```bash +npx clawhub install my-skill +npx clawhub install my-skill@1.2.0 +npx clawhub install team-name--my-skill +``` + +## Common Workflows + +### Search + +```bash +npx clawhub search email +``` + +Use an empty query when you want a broad listing: + +```bash +npx clawhub search "" +``` + +### Inspect A Skill + +```bash +npx clawhub info my-skill +npx clawhub info team-name--my-skill +``` + +### Install + +```bash +npx clawhub install my-skill +npx clawhub install my-skill@1.2.0 +npx clawhub install team-name--my-skill +``` + +### Publish + +Prepare a skill package directory, then publish it: + +```bash +npx clawhub publish ./my-skill +``` + +Publishing requires authentication and sufficient permissions in the target namespace. + +## Authentication And Visibility + +Download and search permissions depend on namespace and visibility: + +- `@global` + `PUBLIC`: anonymous search, inspect, and download are allowed. +- Team namespace + `PUBLIC`: authentication required for download. +- `NAMESPACE_ONLY`: authenticated namespace members only. +- `PRIVATE`: owner or explicitly authorized users only. +- Publish, star, and other write operations always require authentication. + +If a request fails with `403`, check: + +- whether the skill belongs to a team namespace, +- whether the skill is `NAMESPACE_ONLY` or `PRIVATE`, +- whether your token is valid, +- whether you have namespace publish permissions. + +## Skill Package Contract + +SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. + +Minimum valid `SKILL.md` frontmatter: + +```yaml +--- +name: my-skill +description: When to use this skill +--- +``` + +Required structure: + +```text +my-skill/ +├── SKILL.md +├── references/ +├── scripts/ +└── assets/ +``` + +Contract notes: + +- `name` and `description` are required. +- `name` becomes the immutable skill slug on first publish. +- `description` becomes the registry summary. +- `references/`, `scripts/`, and `assets/` are optional. +- The package is treated as a text-first resource bundle, not a binary artifact bucket. + +## Publishing Guidance + +Before publishing: + +1. Ensure `SKILL.md` exists at the package root. +2. Keep the skill name in kebab-case. +3. Make sure the version you are publishing is semver-compatible. +4. Avoid relying on `latest` as a rollback tool; SkillHub keeps `latest` automatically pinned to the newest published version. +5. Use custom tags like `beta` or `stable` for release channels when needed. + +## When To Use Raw HTTP + +Use direct HTTP only for server debugging, contract testing, or compatibility work. Relevant endpoints exposed by the current codebase include: + +- `GET /.well-known/clawhub.json` +- `GET /api/v1/search` +- `GET /api/v1/resolve` +- `GET /api/v1/download/{slug}` +- `GET /api/v1/skills/{slug}` +- `POST /api/v1/publish` +- `GET /api/v1/whoami` + +For normal registry usage, stay on the `clawhub` CLI. + +## Project References + +Read these local documents when you need more detail about SkillHub behavior: + +- `docs/00-product-direction.md` +- `docs/06-api-design.md` +- `docs/07-skill-protocol.md` +- `docs/14-skill-lifecycle.md` +- `docs/openclaw-integration.md` +- `README.md` diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index abc46b53..45c781bc 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -1,14 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' - -export interface AdminUser { - id: string - username: string - email: string - status: 'ACTIVE' | 'DISABLED' - platformRoles: string[] - createdAt: string -} +import { adminApi } from '@/api/client' +import type { AdminUser } from '@/api/types' +export type { AdminUser } from '@/api/types' export interface AdminUsersParams { search?: string @@ -25,30 +18,15 @@ export interface PagedAdminUsers { } async function getAdminUsers(params: AdminUsersParams): Promise { - const searchParams = new URLSearchParams() - if (params.search) searchParams.set('search', params.search) - if (params.status) searchParams.set('status', params.status) - searchParams.set('page', String(params.page ?? 0)) - searchParams.set('size', String(params.size ?? 20)) - - const url = `/api/v1/admin/users?${searchParams.toString()}` - return fetchJson(url) + return adminApi.getUsers(params) } async function updateUserRole(userId: string, role: string): Promise { - await fetchJson(`/api/v1/admin/users/${userId}/role`, { - method: 'PUT', - headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ role }), - }) + await adminApi.updateUserRole(userId, role) } async function updateUserStatus(userId: string, status: 'ACTIVE' | 'DISABLED'): Promise { - await fetchJson(`/api/v1/admin/users/${userId}/status`, { - method: 'PUT', - headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ status }), - }) + await adminApi.updateUserStatus(userId, status) } export function useAdminUsers(params: AdminUsersParams) { @@ -65,6 +43,7 @@ export function useUpdateUserRole() { updateUserRole(userId, role), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } @@ -76,6 +55,40 @@ export function useUpdateUserStatus() { updateUserStatus(userId, status), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + +export function useApproveUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.approveUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + +export function useDisableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.disableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + +export function useEnableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.enableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts index 7a2a3649..c55ab5a7 100644 --- a/web/src/features/admin/use-audit-log.ts +++ b/web/src/features/admin/use-audit-log.ts @@ -1,39 +1,29 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJson } from '@/api/client' - -export interface AuditLog { - id: number - action: string - userId: string - username?: string - details?: string - ipAddress?: string - timestamp: string -} +import { adminApi } from '@/api/client' +import type { AuditLogItem } from '@/api/types' export interface AuditLogParams { action?: string userId?: string + requestId?: string + ipAddress?: string + resourceType?: string + resourceId?: string + startTime?: string + endTime?: string page?: number size?: number } export interface PagedAuditLogs { - items: AuditLog[] + items: AuditLogItem[] total: number page: number size: number } async function getAuditLogs(params: AuditLogParams): Promise { - const searchParams = new URLSearchParams() - if (params.action) searchParams.set('action', params.action) - if (params.userId) searchParams.set('userId', params.userId) - searchParams.set('page', String(params.page ?? 0)) - searchParams.set('size', String(params.size ?? 20)) - - const url = `/api/v1/admin/audit-logs?${searchParams.toString()}` - return fetchJson(url) + return adminApi.getAuditLogs(params) } export function useAuditLog(params: AuditLogParams) { diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index 57db8e2f..a7dad6d1 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -1,21 +1,23 @@ -import { useQuery } from '@tanstack/react-query' -import { authApi } from '@/api/client' +import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' -import type { OAuthProvider } from '@/api/types' +import { useAuthMethods } from './use-auth-methods' -export function LoginButton() { - const { data, isLoading } = useQuery({ - queryKey: ['auth', 'providers'], - queryFn: authApi.getProviders, - }) +interface LoginButtonProps { + returnTo?: string +} + +export function LoginButton({ returnTo }: LoginButtonProps) { + const { t } = useTranslation() + const { data, isLoading } = useAuthMethods(returnTo) - const providers = data ?? [] + const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT') if (isLoading) { return (
-
) @@ -26,12 +28,16 @@ export function LoginButton() { {providers.map((provider) => ( ))}
diff --git a/web/src/features/auth/session-bootstrap-entry.tsx b/web/src/features/auth/session-bootstrap-entry.tsx new file mode 100644 index 00000000..eb2651e2 --- /dev/null +++ b/web/src/features/auth/session-bootstrap-entry.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { ApiError, getSessionBootstrapRuntimeConfig } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { useSessionBootstrap } from './use-session-bootstrap' + +interface SessionBootstrapEntryProps { + onAuthenticated: () => Promise + methodDisplayName?: string +} + +export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: SessionBootstrapEntryProps) { + const { t } = useTranslation() + const config = getSessionBootstrapRuntimeConfig() + const bootstrapMutation = useSessionBootstrap() + const attemptedRef = useRef(false) + const providerName = methodDisplayName || t('login.enterpriseSsoTitle') + + useEffect(() => { + if (!config.enabled || !config.provider || !config.auto || attemptedRef.current) { + return + } + attemptedRef.current = true + void bootstrapMutation.mutateAsync(config.provider, { + onSuccess: async () => { + await onAuthenticated() + }, + onError: () => { + // Fallback to normal login options without surfacing a global auth error. + }, + }) + }, [bootstrapMutation, config.auto, config.enabled, config.provider, onAuthenticated]) + + if (!config.enabled || !config.provider) { + return null + } + + const manualError = bootstrapMutation.error instanceof ApiError + && bootstrapMutation.error.status !== 401 + && bootstrapMutation.error.status !== 403 + ? bootstrapMutation.error.message + : null + + return ( +
+
+

+ {providerName} +

+

+ {config.auto + ? t('login.enterpriseSsoAutoHint', { name: providerName }) + : t('login.enterpriseSsoHint', { name: providerName })} +

+
+ + + + {manualError ? ( +

{manualError}

+ ) : null} +
+ ) +} diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts new file mode 100644 index 00000000..c4f75fe8 --- /dev/null +++ b/web/src/features/auth/use-account-merge.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { accountApi } from '@/api/client' +import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' + +export function useInitiateAccountMerge() { + return useMutation({ + mutationFn: (request: MergeInitiateRequest) => accountApi.initiateMerge(request), + }) +} + +export function useVerifyAccountMerge() { + return useMutation({ + mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), + }) +} + +export function useConfirmAccountMerge() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} diff --git a/web/src/features/auth/use-auth-methods.ts b/web/src/features/auth/use-auth-methods.ts new file mode 100644 index 00000000..b05c82ce --- /dev/null +++ b/web/src/features/auth/use-auth-methods.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { AuthMethod } from '@/api/types' + +export function useAuthMethods(returnTo?: string) { + return useQuery({ + queryKey: ['auth', 'methods', returnTo ?? ''], + queryFn: () => authApi.getMethods(returnTo), + }) +} diff --git a/web/src/features/auth/use-auth.test.ts b/web/src/features/auth/use-auth.test.ts new file mode 100644 index 00000000..49bb858a --- /dev/null +++ b/web/src/features/auth/use-auth.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getAuthQueryOptions } from './use-auth' + +describe('getAuthQueryOptions', () => { + it('keeps auth state fresh so role changes are picked up promptly', () => { + const options = getAuthQueryOptions(true) + + expect(options.queryKey).toEqual(['auth', 'me']) + expect(options.staleTime).toBe(0) + expect(options.refetchOnWindowFocus).toBe(true) + expect(options.refetchOnReconnect).toBe(true) + expect(options.refetchInterval).toBe(60_000) + expect(options.enabled).toBe(true) + }) +}) diff --git a/web/src/features/auth/use-auth.ts b/web/src/features/auth/use-auth.ts index 2ead0532..4a9d66d5 100644 --- a/web/src/features/auth/use-auth.ts +++ b/web/src/features/auth/use-auth.ts @@ -2,13 +2,21 @@ import { useQuery } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { User } from '@/api/types' -export function useAuth() { - const { data: user, isLoading, error } = useQuery({ - queryKey: ['auth', 'me'], +export function getAuthQueryOptions(enabled = true) { + return { + queryKey: ['auth', 'me'] as const, queryFn: authApi.getMe, retry: false, - staleTime: 5 * 60 * 1000, // 5 分钟 - }) + enabled, + staleTime: 0, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: 60_000, + } +} + +export function useAuth(enabled = true) { + const { data: user, isLoading, error } = useQuery(getAuthQueryOptions(enabled)) return { user: user ?? null, diff --git a/web/src/features/auth/use-local-auth.ts b/web/src/features/auth/use-local-auth.ts new file mode 100644 index 00000000..f92e7906 --- /dev/null +++ b/web/src/features/auth/use-local-auth.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { LocalLoginRequest, LocalRegisterRequest, User } from '@/api/types' + +export function useLocalLogin() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: LocalLoginRequest) => authApi.localLogin(request), + onSuccess: (user) => { + queryClient.setQueryData(['auth', 'me'], user) + }, + }) +} + +export function useLocalRegister() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request), + onSuccess: (user) => { + queryClient.setQueryData(['auth', 'me'], user) + }, + }) +} diff --git a/web/src/features/auth/use-password-login.ts b/web/src/features/auth/use-password-login.ts new file mode 100644 index 00000000..a1ce3716 --- /dev/null +++ b/web/src/features/auth/use-password-login.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { authApi, getDirectAuthRuntimeConfig } from '@/api/client' +import { ApiError } from '@/shared/lib/api-error' +import type { LocalLoginRequest, User } from '@/api/types' + +export function usePasswordLogin() { + const queryClient = useQueryClient() + const directAuthConfig = getDirectAuthRuntimeConfig() + + return useMutation({ + mutationFn: (request: LocalLoginRequest) => { + if (directAuthConfig.enabled && directAuthConfig.provider) { + return authApi.directLogin(directAuthConfig.provider, request) + } + return authApi.localLogin(request) + }, + onSuccess: (user) => { + queryClient.setQueryData(['auth', 'me'], user) + }, + onError: (error) => { + // Keep invalid credentials on the login page instead of falling back to the + // global 401 redirect handler used for background API requests. + if (error instanceof ApiError) { + return + } + }, + }) +} diff --git a/web/src/features/auth/use-session-bootstrap.ts b/web/src/features/auth/use-session-bootstrap.ts new file mode 100644 index 00000000..eb9b7d71 --- /dev/null +++ b/web/src/features/auth/use-session-bootstrap.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { User } from '@/api/types' + +export function useSessionBootstrap() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (provider) => authApi.bootstrapSession(provider), + onSuccess: (user) => { + queryClient.setQueryData(['auth', 'me'], user) + }, + }) +} diff --git a/web/src/features/governance/governance-activity.tsx b/web/src/features/governance/governance-activity.tsx new file mode 100644 index 00000000..47a9335f --- /dev/null +++ b/web/src/features/governance/governance-activity.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next' +import type { GovernanceActivityItem } from '@/api/types' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { Card } from '@/shared/ui/card' + +interface GovernanceActivityProps { + items?: GovernanceActivityItem[] + isLoading: boolean +} + +export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps) { + const { t, i18n } = useTranslation() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyActivity')} + } + + return ( +
+ {items.map((item) => ( + +
+
{item.action}
+
+ {item.timestamp ? formatLocalDateTime(item.timestamp, i18n.language) : '-'} +
+
+
+ {item.actorDisplayName || item.actorUserId || t('governance.unknownActor')} +
+ {item.details ?
{item.details}
: null} +
+ ))} +
+ ) +} diff --git a/web/src/features/governance/governance-inbox.tsx b/web/src/features/governance/governance-inbox.tsx new file mode 100644 index 00000000..e998f60c --- /dev/null +++ b/web/src/features/governance/governance-inbox.tsx @@ -0,0 +1,70 @@ +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import type { GovernanceInboxItem } from '@/api/types' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' + +interface GovernanceInboxProps { + items?: GovernanceInboxItem[] + isLoading: boolean +} + +export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyInbox')} + } + + const openItem = (item: GovernanceInboxItem) => { + if (item.type === 'REVIEW') { + navigate({ to: `/dashboard/reviews/${item.id}` }) + return + } + if (item.type === 'PROMOTION') { + navigate({ to: '/dashboard/promotions' }) + return + } + if (item.type === 'REPORT') { + navigate({ to: '/dashboard/reports' }) + return + } + if (item.namespace && item.skillSlug) { + navigate({ to: `/space/${item.namespace}/${item.skillSlug}` }) + } + } + + return ( +
+ {items.map((item) => ( + +
+
+
+ + {item.type} + +
{item.title}
+
+ {item.subtitle ?
{item.subtitle}
: null} +
+
+ {item.timestamp ? formatLocalDateTime(item.timestamp, i18n.language) : '-'} +
+
+
+ +
+
+ ))} +
+ ) +} diff --git a/web/src/features/governance/governance-notifications.tsx b/web/src/features/governance/governance-notifications.tsx new file mode 100644 index 00000000..891a7af3 --- /dev/null +++ b/web/src/features/governance/governance-notifications.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from 'react-i18next' +import type { GovernanceNotification } from '@/api/types' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' + +interface GovernanceNotificationsProps { + items?: GovernanceNotification[] + isLoading: boolean + onMarkRead: (id: number) => void + isMarkingRead: boolean +} + +export function GovernanceNotifications({ items, isLoading, onMarkRead, isMarkingRead }: GovernanceNotificationsProps) { + const { t, i18n } = useTranslation() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyNotifications')} + } + + return ( +
+ {items.map((item) => ( + +
+
{item.title}
+ + {item.status} + +
+ {item.createdAt ? ( +
+ {formatLocalDateTime(item.createdAt, i18n.language)} +
+ ) : null} + {item.bodyJson ?
{item.bodyJson}
: null} + {item.status === 'UNREAD' && item.id ? ( +
+ +
+ ) : null} +
+ ))} +
+ ) +} diff --git a/web/src/features/governance/use-governance.ts b/web/src/features/governance/use-governance.ts new file mode 100644 index 00000000..f55a9e3c --- /dev/null +++ b/web/src/features/governance/use-governance.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { governanceApi } from '@/api/client' + +export function useGovernanceSummary() { + return useQuery({ + queryKey: ['governance', 'summary'], + queryFn: () => governanceApi.getSummary(), + }) +} + +export function useGovernanceInbox(type?: string) { + return useQuery({ + queryKey: ['governance', 'inbox', type ?? 'ALL'], + queryFn: async () => { + const page = await governanceApi.getInbox({ type }) + return page.items + }, + }) +} + +export function useGovernanceActivity() { + return useQuery({ + queryKey: ['governance', 'activity'], + queryFn: async () => { + const page = await governanceApi.getActivity({}) + return page.items + }, + }) +} + +export function useGovernanceNotifications() { + return useQuery({ + queryKey: ['governance', 'notifications'], + queryFn: () => governanceApi.getNotifications(), + }) +} + +export function useMarkGovernanceNotificationRead() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => governanceApi.markNotificationRead(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['governance', 'notifications'] }) + }, + }) +} diff --git a/web/src/features/namespace/add-namespace-member-dialog.tsx b/web/src/features/namespace/add-namespace-member-dialog.tsx new file mode 100644 index 00000000..4dd077d2 --- /dev/null +++ b/web/src/features/namespace/add-namespace-member-dialog.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { NamespaceRole } from '@/api/types' +import { useAddNamespaceMember, useNamespaceMemberCandidates } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select } from '@/shared/ui/select' + +interface AddNamespaceMemberDialogProps { + slug: string + children: React.ReactNode +} + +const ROLE_OPTIONS: NamespaceRole[] = ['MEMBER', 'ADMIN'] + +export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberDialogProps) { + const { t } = useTranslation() + const addMemberMutation = useAddNamespaceMember() + const [open, setOpen] = useState(false) + const [searchInput, setSearchInput] = useState('') + const [appliedSearch, setAppliedSearch] = useState('') + const [userId, setUserId] = useState('') + const [role, setRole] = useState('MEMBER') + const [userIdError, setUserIdError] = useState(null) + const [searchError, setSearchError] = useState(null) + + const { data: candidates, isFetching, error: candidatesError } = useNamespaceMemberCandidates( + slug, + appliedSearch, + open, + ) + + const resetDialog = () => { + setSearchInput('') + setAppliedSearch('') + setUserId('') + setRole('MEMBER') + setUserIdError(null) + setSearchError(null) + addMemberMutation.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + resetDialog() + } + } + + const handleSearch = () => { + const keyword = searchInput.trim() + if (keyword.length > 0 && keyword.length < 2) { + setSearchError(t('members.searchTooShort')) + return + } + setSearchError(null) + setAppliedSearch(keyword) + } + + const handleAddMember = async () => { + const normalizedUserId = userId.trim() + if (!normalizedUserId) { + setUserIdError(t('members.userIdRequired')) + return + } + + try { + await addMemberMutation.mutateAsync({ + slug, + userId: normalizedUserId, + role, + }) + toast.success( + t('members.addSuccessTitle'), + t('members.addSuccessDescription', { userId: normalizedUserId }), + ) + handleOpenChange(false) + } catch (error) { + toast.error(t('members.addErrorTitle'), error instanceof Error ? error.message : '') + } + } + + return ( + + {children} + + + {t('members.addDialogTitle')} + + {t('members.addDialogDescription')} + + + +
+
+ +
+ { + setSearchInput(event.target.value) + if (searchError) { + setSearchError(null) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleSearch() + } + }} + /> + +
+

+ {searchError ?? t('members.searchHint')} +

+
+ + {appliedSearch ? ( +
+
{t('members.searchResultsTitle')}
+ {isFetching ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ ) : candidatesError ? ( +

{candidatesError.message}

+ ) : candidates && candidates.length > 0 ? ( +
+ {candidates.map((candidate) => ( +
+
+
{candidate.displayName}
+
{candidate.email || candidate.userId}
+
{candidate.userId}
+
+ +
+ ))} +
+ ) : ( +

{t('members.searchEmpty')}

+ )} +
+ ) : null} + +
+ + { + setUserId(event.target.value) + if (userIdError) { + setUserIdError(null) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAddMember() + } + }} + aria-invalid={userIdError ? 'true' : 'false'} + /> +

+ {userIdError ?? t('members.manualUserIdHint')} +

+
+ +
+ + +
+
+ + {addMemberMutation.error ? ( +

{addMemberMutation.error.message}

+ ) : null} + + + + + + +
+ ) +} diff --git a/web/src/features/namespace/create-namespace-dialog.tsx b/web/src/features/namespace/create-namespace-dialog.tsx new file mode 100644 index 00000000..733e402b --- /dev/null +++ b/web/src/features/namespace/create-namespace-dialog.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { CreateNamespaceRequest } from '@/api/types' +import { useCreateNamespace } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Textarea } from '@/shared/ui/textarea' + +interface CreateNamespaceDialogProps { + children: React.ReactNode +} + +type FieldErrors = { + slug?: string + displayName?: string + description?: string +} + +const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ +const RESERVED_SLUGS = new Set([ + 'admin', + 'api', + 'dashboard', + 'search', + 'auth', + 'me', + 'global', + 'system', + 'static', + 'assets', + 'health', +]) +const MAX_SLUG_LENGTH = 64 +const MIN_SLUG_LENGTH = 2 +const MAX_DISPLAY_NAME_LENGTH = 128 +const MAX_DESCRIPTION_LENGTH = 512 + +function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, options?: Record) => string): FieldErrors { + const errors: FieldErrors = {} + const slug = request.slug.trim() + const displayName = request.displayName.trim() + const description = request.description?.trim() ?? '' + + if (!slug) { + errors.slug = t('myNamespaces.createSlugRequired') + } else if (slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + errors.slug = t('myNamespaces.createSlugLength', { min: MIN_SLUG_LENGTH, max: MAX_SLUG_LENGTH }) + } else if (!SLUG_PATTERN.test(slug)) { + errors.slug = t('myNamespaces.createSlugPattern') + } else if (slug.includes('--')) { + errors.slug = t('myNamespaces.createSlugDoubleHyphen') + } else if (RESERVED_SLUGS.has(slug)) { + errors.slug = t('myNamespaces.createSlugReserved', { slug }) + } + + if (!displayName) { + errors.displayName = t('myNamespaces.createDisplayNameRequired') + } else if (displayName.length > MAX_DISPLAY_NAME_LENGTH) { + errors.displayName = t('myNamespaces.createDisplayNameLength', { max: MAX_DISPLAY_NAME_LENGTH }) + } + + if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.description = t('myNamespaces.createDescriptionLength', { max: MAX_DESCRIPTION_LENGTH }) + } + + return errors +} + +export function CreateNamespaceDialog({ children }: CreateNamespaceDialogProps) { + const { t } = useTranslation() + const createMutation = useCreateNamespace() + const [open, setOpen] = useState(false) + const [slug, setSlug] = useState('') + const [displayName, setDisplayName] = useState('') + const [description, setDescription] = useState('') + const [errors, setErrors] = useState({}) + + const resetDialog = () => { + setSlug('') + setDisplayName('') + setDescription('') + setErrors({}) + createMutation.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + resetDialog() + } + } + + const normalizedRequest: CreateNamespaceRequest = { + slug: slug.trim().toLowerCase(), + displayName: displayName.trim(), + description: description.trim() || undefined, + } + + const handleSubmit = async () => { + const nextErrors = buildFieldErrors(normalizedRequest, t) + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors) + return + } + + try { + const namespace = await createMutation.mutateAsync(normalizedRequest) + toast.success( + t('myNamespaces.createSuccessTitle'), + t('myNamespaces.createSuccessDescription', { name: namespace.displayName }), + ) + handleOpenChange(false) + } catch (error) { + toast.error(t('myNamespaces.createErrorTitle'), error instanceof Error ? error.message : '') + } + } + + const slugLength = slug.trim().length + const displayNameLength = displayName.trim().length + const descriptionLength = description.trim().length + + return ( + + {children} + + + {t('myNamespaces.createDialogTitle')} + + {t('myNamespaces.createDialogDescription')} + + + +
+
+ + { + setSlug(event.target.value.toLowerCase()) + if (errors.slug) { + setErrors((current) => ({ ...current, slug: undefined })) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + handleSubmit() + } + }} + aria-invalid={errors.slug ? 'true' : 'false'} + /> +
+ {errors.slug ?? t('myNamespaces.createSlugHint')} + {slugLength}/{MAX_SLUG_LENGTH} +
+
+ +
+ + { + setDisplayName(event.target.value) + if (errors.displayName) { + setErrors((current) => ({ ...current, displayName: undefined })) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + handleSubmit() + } + }} + aria-invalid={errors.displayName ? 'true' : 'false'} + /> +
+ {errors.displayName ?? ''} + {displayNameLength}/{MAX_DISPLAY_NAME_LENGTH} +
+
+ +
+ +