diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e7f9c8f3..e5ce7df0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,6 +29,11 @@ body: 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: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b40231a6..3ed8eaeb 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Security Report - url: https://example.invalid/security-contact + 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 index 0cbacbfd..f3ae637d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -26,3 +26,8 @@ body: 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/pull_request_template.md b/.github/pull_request_template.md index a74bf0af..1e9094b2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,6 +7,7 @@ - [ ] Backend tests passed - [ ] Frontend typecheck/build passed +- [ ] OpenAPI SDK regenerated or checked when API contracts changed - [ ] Smoke test run when relevant Commands run: @@ -25,3 +26,4 @@ Commands run: - Related issue: - Follow-up work: +- Docs or operator runbooks updated when behavior changed: diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml new file mode 100644 index 00000000..c912e046 --- /dev/null +++ b/.github/workflows/validate-openapi.yml @@ -0,0 +1,43 @@ +name: Validate OpenAPI SDK + +on: + pull_request: + push: + branches: + - main + - feature/project-init + +jobs: + openapi-sdk: + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install frontend dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Validate generated OpenAPI SDK + run: ./scripts/check-openapi-generated.sh diff --git a/.gitignore b/.gitignore index c0900a12..2129c847 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ coverage/ # Temporary files .tmp/ tmp/ +__pycache__/ +*.py[cod] # Git worktrees .worktrees/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 90643adc..f1dc7ad0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -29,5 +29,5 @@ project spaces. ## Reporting -Report conduct issues privately to the maintainers through an internal contact +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 index 99f87e14..a382e5d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,8 @@ Useful commands: make test make typecheck-web make build-web +make generate-api +./scripts/check-openapi-generated.sh ./scripts/smoke-test.sh ``` @@ -47,6 +49,8 @@ make dev-all-down - 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 @@ -56,6 +60,8 @@ 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. @@ -71,5 +77,5 @@ Conventional-style subjects are preferred, for example: Do not open public issues for suspected security vulnerabilities. -Report them privately to the maintainers through your internal security process -or a private maintainer contact channel. +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 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2026 iFLYTEK + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -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: + 1. Definitions. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -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. + "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 b33b02a5..ed926367 100644 --- a/Makefile +++ b/Makefile @@ -26,13 +26,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 -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/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..."; \ @@ -40,13 +40,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 './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \ fi; \ - sleep 2; \ done; \ if [ "$$backend_ready" -ne 1 ]; then \ echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ diff --git a/README.md b/README.md index c83f177b..aaa2c4c8 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,12 @@ 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. - **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. @@ -71,6 +73,25 @@ make dev-all-reset Run `make help` to see all available commands. +### 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. @@ -212,4 +233,4 @@ what you'd like to change. ## License -MIT +Apache License 2.0 diff --git a/scripts/check-openapi-generated.sh b/scripts/check-openapi-generated.sh new file mode 100755 index 00000000..2cc21ef5 --- /dev/null +++ b/scripts/check-openapi-generated.sh @@ -0,0 +1,46 @@ +#!/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" +SERVER_PID="" + +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 ./mvnw -pl skillhub-app spring-boot:run +) >"$API_LOG" 2>&1 & +SERVER_PID=$! + +for _ in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +if ! curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then + echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2 + 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.py b/scripts/dev_process.py index 3213c108..8dd33f56 100644 --- a/scripts/dev_process.py +++ b/scripts/dev_process.py @@ -60,6 +60,11 @@ def start_process(args: argparse.Namespace) -> int: start_new_session=True, ) + time.sleep(0.2) + if process.poll() is not None: + pid_file.unlink(missing_ok=True) + return process.returncode or 1 + write_pid(pid_file, process.pid) print(process.pid) return 0 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index e4fc7382..add4c748 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -21,7 +21,7 @@ check() { local url="$2" local expected="$3" local status - status="$(curl -s -o /dev/null -w "%{http_code}" "$url")" + 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)) @@ -43,13 +43,13 @@ 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 -s -o /dev/null -w "%{http_code}" \ +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\"}")" + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"email\":\"$EMAIL\"}" || true)" if [[ "$REGISTER_STATUS" == "200" ]]; then echo "PASS: Register (HTTP $REGISTER_STATUS)" PASS=$((PASS + 1)) @@ -58,7 +58,7 @@ else FAIL=$((FAIL + 1)) fi -AUTH_ME_STATUS="$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me")" +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)) @@ -67,12 +67,12 @@ else FAIL=$((FAIL + 1)) fi -CHANGE_PASSWORD_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ +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\"}")" + -d "{\"currentPassword\":\"$PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}" || true)" if [[ "$CHANGE_PASSWORD_STATUS" == "200" ]]; then echo "PASS: Change password (HTTP $CHANGE_PASSWORD_STATUS)" PASS=$((PASS + 1)) @@ -81,11 +81,11 @@ else FAIL=$((FAIL + 1)) fi -LOGOUT_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ +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")" + -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)) @@ -94,7 +94,7 @@ else FAIL=$((FAIL + 1)) fi -POST_LOGOUT_STATUS="$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me")" +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)) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java new file mode 100644 index 00000000..1879e54f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java @@ -0,0 +1,100 @@ +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.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Seeds default admin account for Docker one-click startup. + * Idempotent: skips if admin credential already exists. + */ +@Component +@Profile("docker") +public class DockerSeedDataRunner implements ApplicationRunner { + + private static final String ADMIN_USER_ID = "docker-admin"; + private static final String ADMIN_USERNAME = "admin"; + private static final String ADMIN_PASSWORD = "Admin@2026"; + + private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); + + 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 DockerSeedDataRunner(UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + 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 (localCredentialRepository.existsByUsernameIgnoreCase(ADMIN_USERNAME)) { + log.info("Docker seed data already exists, skipping"); + return; + } + + // 1. Create admin user account + UserAccount admin = userAccountRepository.findById(ADMIN_USER_ID) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(ADMIN_USER_ID, "Admin", "admin@skillhub.dev", null) + )); + + // 2. Create local credential (username/password) + localCredentialRepository.save( + new LocalCredential(admin.getId(), ADMIN_USERNAME, passwordEncoder.encode(ADMIN_PASSWORD)) + ); + + // 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("Docker seed data initialized — admin account: {} / {}", ADMIN_USERNAME, ADMIN_PASSWORD); + } +} 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 70717170..3bbf4a18 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,39 +1,130 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +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.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.io.IOException; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Map; +import org.slf4j.MDC; @RestController @RequestMapping("/api/compat/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 AuditLogService auditLogService; - public ClawHubCompatController(CanonicalSlugMapper mapper) { + public ClawHubCompatController(CanonicalSlugMapper mapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, + AuditLogService auditLogService) { this.mapper = mapper; + this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; + this.auditLogService = auditLogService; } @GetMapping("/search") - public ClawHubSearchResponse search(@RequestParam String q) { - // Return empty results for now (placeholder) - return new ClawHubSearchResponse(List.of()); + public ClawHubSearchResponse search(@RequestParam String q, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + var result = skillSearchAppService.search(q, null, "relevance", 0, 20, userId, userNsRoles != null ? userNsRoles : Map.of()); + return new ClawHubSearchResponse(result.items().stream() + .map(item -> new ClawHubSkillItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.summary(), + item.latestVersion(), + item.starCount() + )) + .toList()); } @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); + var resolved = skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + null, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); return new ClawHubResolveResponse( canonicalSlug, - version, - "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + resolved.version(), + resolved.downloadUrl() + ); + } + + @GetMapping("/download/{canonicalSlug}") + public ResponseEntity download(@PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + 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(); + } + + @PostMapping("/publish") + public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, + @RequestParam("namespace") String namespace, + @RequestAttribute("userId") String userId, + jakarta.servlet.http.HttpServletRequest request) throws IOException { + var result = skillPublishService.publishFromEntries( + namespace, + zipPackageExtractor.extract(file), + userId, + SkillVisibility.PUBLIC + ); + auditLogService.record( + userId, + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); + return new ClawHubPublishResponse( + mapper.toCanonical(namespace, result.slug()), + result.version().getVersion(), + result.version().getStatus().name() ); } 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 index 9288aba0..7e812f96 100644 --- 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 @@ -49,11 +49,23 @@ public class AccountMergeController extends BaseApiController { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - accountMergeService.verifyAndComplete( + 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/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index cf0273ec..d5b031b1 100644 --- 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 @@ -7,7 +7,10 @@ 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.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillCheckResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.*; @@ -24,11 +27,14 @@ import java.util.zip.ZipInputStream; public class CliController extends BaseApiController { private final SkillPackageValidator skillPackageValidator; + private final SkillQueryService skillQueryService; public CliController(ApiResponseFactory responseFactory, - SkillPackageValidator skillPackageValidator) { + SkillPackageValidator skillPackageValidator, + SkillQueryService skillQueryService) { super(responseFactory); this.skillPackageValidator = skillPackageValidator; + this.skillQueryService = skillQueryService; } @GetMapping("/whoami") @@ -55,6 +61,35 @@ public class CliController extends BaseApiController { return ok("response.success.validated", response); } + @GetMapping("/resolve/{namespace}/{slug}") + public ApiResponse resolve(@PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) java.util.Map userNsRoles) { + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + namespace, + slug, + version, + tag, + hash, + userId, + userNsRoles != null ? userNsRoles : java.util.Map.of() + ); + return ok("response.success.read", new ResolveVersionResponse( + resolved.skillId(), + resolved.namespace(), + resolved.slug(), + resolved.version(), + resolved.versionId(), + resolved.fingerprint(), + resolved.matched(), + resolved.downloadUrl() + )); + } + private List extractZipEntries(MultipartFile file) throws IOException { List entries = new ArrayList<>(); 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 @@ package com.iflytek.skillhub.controller; 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 @@ import org.springframework.web.bind.annotation.RestController; 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/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 8f4bb657..1d6363b4 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 @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.admin; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; @@ -8,39 +9,42 @@ import com.iflytek.skillhub.dto.AdminUserSummaryResponse; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserManagementService; import jakarta.validation.Valid; -import org.springframework.data.domain.PageImpl; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/api/v1/admin/users") public class UserManagementController extends BaseApiController { - public UserManagementController(ApiResponseFactory responseFactory) { + private final AdminUserManagementService adminUserManagementService; + + public UserManagementController(ApiResponseFactory responseFactory, + AdminUserManagementService adminUserManagementService) { super(responseFactory); + this.adminUserManagementService = adminUserManagementService; } @GetMapping @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse> listUsers( + @RequestParam(required = false) String search, + @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - List users = List.of( - new AdminUserSummaryResponse("user-1", "alice", "USER", "ACTIVE"), - new AdminUserSummaryResponse("user-2", "bob", "USER", "ACTIVE") - ); - return ok("response.success.read", PageResponse.from(new PageImpl<>(users))); + return ok("response.success.read", adminUserManagementService.listUsers(search, status, page, size)); } @PutMapping("/{userId}/role") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse updateUserRole( @PathVariable String userId, - @Valid @RequestBody AdminUserRoleUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, request.role(), null)); + @Valid @RequestBody AdminUserRoleUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + AdminUserSummaryResponse user = adminUserManagementService.updateUserRole(userId, request.role(), principal); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), request.role(), user.status())); } @PutMapping("/{userId}/status") @@ -48,6 +52,28 @@ public class UserManagementController extends BaseApiController { public ApiResponse updateUserStatus( @PathVariable String userId, @Valid @RequestBody AdminUserStatusUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, null, request.status())); + AdminUserSummaryResponse user = adminUserManagementService.updateUserStatus(userId, request.status()); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/approve") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse approveUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.approveUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/disable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse disableUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.disableUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/enable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse enableUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); } } 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 index e4e0ea7e..c1a743b9 100644 --- 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 @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -10,6 +11,8 @@ 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 jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -23,15 +26,18 @@ public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final ZipPackageExtractor zipPackageExtractor; private final SkillHubMetrics skillHubMetrics; + private final AuditLogService auditLogService; public CliPublishController(SkillPublishService skillPublishService, ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + AuditLogService auditLogService) { super(responseFactory); this.skillPublishService = skillPublishService; this.zipPackageExtractor = zipPackageExtractor; this.skillHubMetrics = skillHubMetrics; + this.auditLogService = auditLogService; } @PostMapping("/publish") @@ -40,7 +46,8 @@ public class CliPublishController extends BaseApiController { @RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") String userId) throws IOException { + @RequestAttribute("userId") String userId, + HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -63,6 +70,16 @@ public class CliPublishController extends BaseApiController { publishResult.version().getTotalSize() ); skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); + auditLogService.record( + userId, + "CLI_PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); return ok("response.success.published", response); } 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..630cc91c 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 @@ -34,4 +34,14 @@ public class MeController extends BaseApiController { return ok("response.success.read", mySkillAppService.listMySkills(principal.userId())); } + + @GetMapping("/stars") + public ApiResponse> listMyStars( + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMyStars(principal.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 eb074de5..f7e541cf 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,6 +2,7 @@ package com.iflytek.skillhub.controller.portal; 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; @@ -18,12 +19,14 @@ 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 jakarta.servlet.http.HttpServletRequest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Set; +import org.slf4j.MDC; @RestController @RequestMapping("/api/v1/promotions") @@ -36,6 +39,7 @@ public class PromotionController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; + private final AuditLogService auditLogService; public PromotionController(PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, @@ -44,6 +48,7 @@ public class PromotionController extends BaseApiController { NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.promotionService = promotionService; @@ -53,18 +58,22 @@ public class PromotionController extends BaseApiController { this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; + this.auditLogService = auditLogService; } @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @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(), rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_SUBMIT", userId, promotion.getId(), httpRequest, + "{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"); return ok("response.success.created", toResponse(promotion)); } @@ -72,10 +81,12 @@ public class PromotionController extends BaseApiController { public ApiResponse approvePromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + @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); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } @@ -83,13 +94,31 @@ public class PromotionController extends BaseApiController { public ApiResponse rejectPromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + @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); + 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); + boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); + if (!hasAdminRole) { + 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, @@ -152,4 +181,28 @@ public class PromotionController extends BaseApiController { req.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 36429752..6e766132 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,6 +2,7 @@ package com.iflytek.skillhub.controller.portal; 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; @@ -18,12 +19,15 @@ 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 jakarta.servlet.http.HttpServletRequest; 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 java.util.Map; import java.util.Set; +import org.slf4j.MDC; @RestController @RequestMapping("/api/v1/reviews") @@ -36,6 +40,7 @@ public class ReviewController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; + private final AuditLogService auditLogService; public ReviewController(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, @@ -44,6 +49,7 @@ public class ReviewController extends BaseApiController { NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.reviewService = reviewService; @@ -53,19 +59,22 @@ public class ReviewController extends BaseApiController { this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; + this.auditLogService = auditLogService; } @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { ReviewTask task = reviewService.submitReview( request.skillVersionId(), userId, userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId) ); + recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}"); return ok("response.success.created", toResponse(task)); } @@ -74,11 +83,13 @@ public class ReviewController extends BaseApiController { @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @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); + recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @@ -87,23 +98,59 @@ public class ReviewController extends BaseApiController { @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @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); + 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) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); 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(); + Page responsePage = new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size()); + return ok("response.success.read", PageResponse.from(responsePage)); + } + @GetMapping("/pending") public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, @@ -180,4 +227,34 @@ public class ReviewController extends BaseApiController { 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 90dde642..e265ce57 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 @@ -64,6 +64,9 @@ public class SkillController extends BaseApiController { detail.status(), detail.downloadCount(), detail.starCount(), + detail.ratingAvg(), + detail.ratingCount(), + detail.hidden(), detail.latestVersion(), namespace ); 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 3d569967..3412fb1a 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,9 +1,14 @@ package com.iflytek.skillhub.dto; +import java.time.LocalDateTime; +import java.util.List; + public record AdminUserSummaryResponse( String userId, String username, - String role, - String status + String email, + List platformRoles, + String status, + LocalDateTime createdAt ) { } 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..12d0019a 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,9 @@ public record SkillDetailResponse( String status, Long downloadCount, Integer starCount, + BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, String namespace ) {} 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..7be258b7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java @@ -0,0 +1,140 @@ +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.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); + return new AdminUserSummaryResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + List.copyOf(roles), + user.getStatus().name(), + user.getCreatedAt() + ); + } + + private String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } + + 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/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index b262621f..6f2684f0 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 @@ -5,8 +5,10 @@ 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.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; import org.springframework.stereotype.Service; +import org.springframework.data.domain.PageRequest; import java.util.Comparator; import java.util.List; @@ -21,14 +23,17 @@ public class MySkillAppService { private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillStarRepository skillStarRepository; public MySkillAppService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + SkillVersionRepository skillVersionRepository, + SkillStarRepository skillStarRepository) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; + this.skillStarRepository = skillStarRepository; } public List listMySkills(String userId) { @@ -62,6 +67,50 @@ public class MySkillAppService { .toList(); } + public List listMyStars(String userId) { + List stars = skillStarRepository.findByUserId( + userId, + PageRequest.of(0, 200) + ).getContent(); + + List skillIds = stars.stream() + .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) + .distinct() + .toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List latestVersionIds = skillsById.values().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 = skillsById.values().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)); + + return stars.stream() + .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) + .map(star -> skillsById.get(star.getSkillId())) + .filter(java.util.Objects::nonNull) + .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .toList(); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1c0274c9..59782dab 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -100,5 +100,10 @@ error.auth.merge.pendingExists=A pending merge request already exists for this s error.auth.merge.localCredentialConflict=Both accounts already have local credentials error.auth.merge.requestNotFound=Merge request not found error.auth.merge.requestNotPending=Merge request is not pending +error.auth.merge.requestNotVerified=Merge request is not verified error.auth.merge.tokenExpired=Merge verification token has expired error.auth.merge.invalidToken=Invalid merge verification token +error.admin.role.assign_super_admin_forbidden=Only SUPER_ADMIN can assign the SUPER_ADMIN role +error.role.notFound=Role not found: {0} +error.user.notFound=User not found: {0} +error.user.status.invalid=Invalid user status: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index fda7b01c..e2a62d34 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -100,5 +100,10 @@ error.auth.merge.pendingExists=该待合并账号已有进行中的合并请求 error.auth.merge.localCredentialConflict=两个账号都已启用本地密码登录,无法自动合并 error.auth.merge.requestNotFound=未找到合并请求 error.auth.merge.requestNotPending=该合并请求不处于待验证状态 +error.auth.merge.requestNotVerified=该合并请求尚未完成验证 error.auth.merge.tokenExpired=合并验证 token 已过期 error.auth.merge.invalidToken=合并验证 token 无效 +error.admin.role.assign_super_admin_forbidden=只有 SUPER_ADMIN 才能分配 SUPER_ADMIN 角色 +error.role.notFound=角色不存在:{0} +error.user.notFound=用户不存在:{0} +error.user.status.invalid=非法的用户状态:{0} 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 90e6b164..aa93b433 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,9 @@ package com.iflytek.skillhub.compat; 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.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -14,8 +17,12 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Map; import java.util.Set; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +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; @@ -35,8 +42,17 @@ class ClawHubCompatControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private SkillSearchAppService skillSearchAppService; + + @MockBean + private SkillQueryService skillQueryService; + @Test void search_returns_200() throws Exception { + given(skillSearchAppService.search("test", null, "relevance", 0, 20, null, Map.of())) + .willReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + mockMvc.perform(get("/api/compat/v1/search") .param("q", "test")) .andExpect(status().isOk()) @@ -46,6 +62,25 @@ class ClawHubCompatControllerTest { @Test void resolve_returns_correct_downloadUrl() throws Exception { + given(skillQueryService.resolveVersion( + eq("global"), + eq("my-skill"), + isNull(), + eq("latest"), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "global", + "my-skill", + "latest", + 1L, + "sha256:test", + true, + "/api/v1/skills/global/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) @@ -55,6 +90,25 @@ class ClawHubCompatControllerTest { @Test void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { + given(skillQueryService.resolveVersion( + eq("team-ai"), + eq("my-skill"), + isNull(), + eq("latest"), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "team-ai", + "my-skill", + "latest", + 1L, + "sha256:test", + true, + "/api/v1/skills/team-ai/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) @@ -64,6 +118,25 @@ class ClawHubCompatControllerTest { @Test void resolve_with_version_returns_specified_version() throws Exception { + given(skillQueryService.resolveVersion( + eq("global"), + eq("my-skill"), + eq("1.0.0"), + isNull(), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "global", + "my-skill", + "1.0.0", + 2L, + "sha256:test", + true, + "/api/v1/skills/global/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/my-skill") .param("version", "1.0.0")) .andExpect(status().isOk()) 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 index 167c0f1e..7d0afcd6 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -73,6 +74,27 @@ class AccountMergeControllerTest { """)) .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/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index f642cb7c..3ec1746d 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 @@ -4,6 +4,9 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserManagementService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -17,7 +20,9 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import java.time.LocalDateTime; +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; @@ -41,6 +46,9 @@ class UserManagementControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private AdminUserManagementService adminUserManagementService; + @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) @@ -49,6 +57,17 @@ class UserManagementControllerTest { @Test void listUsers_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.listUsers(null, null, 0, 20)) + .willReturn(new PageResponse<>( + List.of( + new AdminUserSummaryResponse("user-1", "alice", "alice@example.com", List.of("USER"), "ACTIVE", LocalDateTime.parse("2026-03-12T12:00:00")), + new AdminUserSummaryResponse("user-2", "bob", "bob@example.com", List.of("USER_ADMIN"), "PENDING", LocalDateTime.parse("2026-03-12T13:00:00")) + ), + 2, + 0, + 20 + )); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -65,6 +84,14 @@ class UserManagementControllerTest { @Test void listUsers_withSuperAdminRole_returns200() throws Exception { + given(adminUserManagementService.listUsers(null, null, 0, 20)) + .willReturn(new PageResponse<>( + List.of(new AdminUserSummaryResponse("user-99", "superadmin", "super@example.com", List.of("SUPER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T14:00:00"))), + 1, + 0, + 20 + )); + PlatformPrincipal principal = new PlatformPrincipal( "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") ); @@ -79,6 +106,9 @@ class UserManagementControllerTest { @Test void updateUserRole_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.updateUserRole(org.mockito.ArgumentMatchers.eq("user-123"), org.mockito.ArgumentMatchers.eq("USER_ADMIN"), org.mockito.ArgumentMatchers.any())) + .willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T15:00:00"))); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -86,7 +116,7 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"role\":\"MODERATOR\"}"; + String requestBody = "{\"role\":\"USER_ADMIN\"}"; mockMvc.perform(put("/api/v1/admin/users/user-123/role") .with(authentication(auth)) @@ -96,11 +126,14 @@ class UserManagementControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-123")) - .andExpect(jsonPath("$.data.role").value("MODERATOR")); + .andExpect(jsonPath("$.data.role").value("USER_ADMIN")); } @Test void updateUserStatus_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.updateUserStatus("user-123", "DISABLED")) + .willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER"), "DISABLED", LocalDateTime.parse("2026-03-12T16:00:00"))); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -108,7 +141,7 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"status\":\"BANNED\"}"; + String requestBody = "{\"status\":\"DISABLED\"}"; mockMvc.perform(put("/api/v1/admin/users/user-123/status") .with(authentication(auth)) @@ -118,6 +151,6 @@ class UserManagementControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-123")) - .andExpect(jsonPath("$.data.status").value("BANNED")); + .andExpect(jsonPath("$.data.status").value("DISABLED")); } } 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 01fa1032..660e4c3c 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 @@ -78,7 +78,8 @@ public class SecurityConfig { "/swagger-ui/**", "/.well-known/**", "/api/compat/v1/search", - "/api/compat/v1/resolve/**" + "/api/compat/v1/resolve/**", + "/api/compat/v1/download/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/skills", 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..871f79cb 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 @@ -5,6 +5,7 @@ import com.iflytek.skillhub.auth.oauth.OAuthClaims; 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; @@ -20,13 +21,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 +58,9 @@ public class IdentityBindingService { ); 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); 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 index ab779e74..1addd5f7 100644 --- 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 @@ -3,6 +3,7 @@ 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.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; @@ -28,17 +29,20 @@ public class LocalAuthService { private final LocalCredentialRepository credentialRepository; private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; } @@ -76,6 +80,7 @@ public class LocalAuthService { normalizedUsername, passwordEncoder.encode(password) )); + globalNamespaceMembershipService.ensureMember(user.getId()); return buildPrincipal(user); } 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 index c1c96a19..b671f60b 100644 --- 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 @@ -14,6 +14,7 @@ import java.time.LocalDateTime; 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"; 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 index 7a571bc5..9badccb5 100644 --- 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 @@ -100,7 +100,7 @@ public class AccountMergeService { } @Transactional - public void verifyAndComplete(String primaryUserId, Long mergeRequestId, String verificationToken) { + 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())) { @@ -113,6 +113,23 @@ public class AccountMergeService { 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")); 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 76aeaf6b..ceb753c3 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 @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import java.util.Collection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -8,4 +9,6 @@ import java.util.List; @Repository public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); + List findByUserIdIn(Collection userIds); + long deleteByUserId(String userId); } 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..f3d4d659 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -0,0 +1,94 @@ +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.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; + +@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()); + } +} 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 index 361083da..8fc505cc 100644 --- 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 @@ -11,6 +11,7 @@ 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; @@ -38,6 +39,9 @@ class LocalAuthServiceTest { @Mock private UserRoleBindingRepository userRoleBindingRepository; + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + @Mock private PasswordEncoder passwordEncoder; @@ -49,6 +53,7 @@ class LocalAuthServiceTest { credentialRepository, userAccountRepository, userRoleBindingRepository, + globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder ); @@ -70,6 +75,7 @@ class LocalAuthServiceTest { assertThat(principal.displayName()).isEqualTo("alice"); assertThat(principal.email()).isEqualTo("alice@example.com"); verify(credentialRepository).save(any(LocalCredential.class)); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); } @Test 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 index cd1ecbce..fa38a89d 100644 --- 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 @@ -23,6 +23,8 @@ 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.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -91,10 +93,29 @@ class AccountMergeServiceTest { } @Test - void verifyAndComplete_migratesBindingsRolesTokensAndMemberships() { + 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 = new AccountMergeRequest("usr_primary", "usr_secondary", "encoded", java.time.LocalDateTime.now().plusMinutes(10)); + 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); @@ -102,10 +123,10 @@ class AccountMergeServiceTest { ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]"); NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN); - given(mergeRequestRepository.findByIdAndPrimaryUserId(request.getId(), "usr_primary")).willReturn(Optional.of(request)); + 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)); 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()); @@ -115,7 +136,7 @@ class AccountMergeServiceTest { given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty()); - service.verifyAndComplete("usr_primary", request.getId(), "raw-token"); + service.confirm("usr_primary", 7L); assertThat(binding.getUserId()).isEqualTo("usr_primary"); assertThat(token.getUserId()).isEqualTo("usr_primary"); @@ -123,21 +144,35 @@ class AccountMergeServiceTest { 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.getVerificationToken()).isNull(); verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); } @Test - void verifyAndComplete_rejectsInvalidToken() { - UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); - AccountMergeRequest request = new AccountMergeRequest("usr_primary", "usr_secondary", "encoded", java.time.LocalDateTime.now().plusMinutes(10)); - given(mergeRequestRepository.findByIdAndPrimaryUserId(request.getId(), "usr_primary")).willReturn(Optional.of(request)); + 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.verifyAndComplete("usr_primary", request.getId(), "bad-token")) + 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, + LocalDateTime.now().plusMinutes(10) + ); + Field idField = AccountMergeRequest.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, 7L); + return request; + } } 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/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/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0b127f28..e4953ebf 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 @@ -61,6 +61,9 @@ public class SkillQueryService { String status, Long downloadCount, Integer starCount, + java.math.BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, Long namespaceId ) {} @@ -120,6 +123,9 @@ public class SkillQueryService { skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), + skill.getRatingAvg(), + skill.getRatingCount(), + skill.isHidden(), latestVersion, skill.getNamespaceId() ); 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 fdd8f21a..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,9 +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/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-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..887d2476 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -0,0 +1,64 @@ +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.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 Optional findByNamespaceIdAndSlug(Long namespaceId, String slug) { + return delegate.findByNamespaceIdAndSlug(namespaceId, slug); + } + + @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 List findByOwnerId(String ownerId) { + return delegate.findByOwnerId(ownerId); + } + + @Override + public void incrementDownloadCount(Long skillId) { + delegate.incrementDownloadCount(skillId); + } +} 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/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index 4f6e9eb7..d5a907be 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,10 +2,31 @@ package com.iflytek.skillhub.infra.jpa; 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.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserAccountJpaRepository extends JpaRepository, 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/web/src/api/client.ts b/web/src/api/client.ts index 0fafbc7f..6bcedc04 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -5,11 +5,17 @@ import type { ApiToken, CreateTokenRequest, CreateTokenResponse, + MergeConfirmRequest, LocalLoginRequest, LocalRegisterRequest, MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + ReviewTask, + PromotionTask, + AdminUser, + AuditLogItem, + SkillSummary, OAuthProvider, User, } from './types' @@ -113,7 +119,13 @@ export async function fetchText(input: RequestInfo | URL, init?: RequestInit): P 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') as never) + return { + ...user, + userId: user.userId ?? '', + displayName: user.displayName ?? '', + platformRoles: user.platformRoles ?? [], + } } catch (error) { if (error instanceof Error && error.message === 'HTTP 401') { return null @@ -126,7 +138,15 @@ export const authApi = { getMe: getCurrentUser, async getProviders(): Promise { - return unwrap(client.GET('/api/v1/auth/providers') as never) + const providers = await unwrap(client.GET('/api/v1/auth/providers') 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 localLogin(request: LocalLoginRequest): Promise { @@ -160,10 +180,11 @@ export const authApi = { }, 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}`) } }, @@ -189,20 +210,50 @@ export const accountApi = { 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 tokenApi = { async getTokens(): Promise { - return unwrap(client.GET('/api/v1/tokens') as never) + const tokens = await unwrap(client.GET('/api/v1/tokens') as never) + return tokens + .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 deleteToken(tokenId: number): Promise { @@ -219,3 +270,168 @@ export const tokenApi = { } }, } + +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 }>( + `/api/v1/reviews?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`/api/v1/reviews/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/reviews/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment: string): Promise { + await fetchJson(`/api/v1/reviews/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const promotionApi = { + 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 }>( + `/api/v1/promotions?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`/api/v1/promotions/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/promotions/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/promotions/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const meApi = { + async getStars(): Promise { + return fetchJson('/api/v1/me/stars') + }, +} + +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)) + return fetchJson<{ items: AdminUser[]; total: number; page: number; size: number }>( + `/api/v1/admin/users?${searchParams.toString()}`, + ) + }, + + 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; page?: number; size?: number }) { + 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)) + 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..8107fca8 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1,125 +1,3840 @@ -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 - } - } - } - } -} +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ -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'] - } - } +export interface paths { + "/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/cli/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/cli/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/cli/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/cli/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/compat/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/cli/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/cli/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/compat/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/compat/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/compat/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/compat/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: { + 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; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + latestVersion?: string; + namespace?: string; + /** Format: date-time */ + updatedAt?: 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; + latestVersion?: string; + namespace?: 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 9929ee2c..3cd532b3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,10 +1,42 @@ 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 type ApiToken = Omit & { + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string + lastUsedAt?: string +} + +export type CreateTokenRequest = Omit & { + name: string + scopes?: string[] +} + +export type CreateTokenResponse = Omit & { + token: string + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string +} export interface LocalLoginRequest { username: string @@ -36,6 +68,10 @@ export interface MergeVerifyRequest { verificationToken: string } +export interface MergeConfirmRequest { + mergeRequestId: number +} + // Namespace types export interface Namespace { id: number @@ -80,6 +116,9 @@ export interface SkillDetail { status: string downloadCount: number starCount: number + ratingAvg?: number + ratingCount: number + hidden: boolean latestVersion?: string namespace: string } @@ -135,3 +174,56 @@ 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 AdminUser { + userId: string + username: string + email?: string + platformRoles: string[] + status: string + createdAt: string +} + +export interface AuditLogItem { + id: string + userId?: string + action: string + resourceType?: string + resourceId?: string + timestamp: string + ipAddress?: string +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 8418a4cb..e541adcf 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,9 +1,16 @@ import { Suspense } from 'react' -import { Outlet, Link } from '@tanstack/react-router' +import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' +import { LandingPage } from '@/pages/landing' export function Layout() { const { user, isLoading } = useAuth() + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const isLanding = pathname === '/' + + if (isLanding) { + return + } return (
@@ -64,6 +71,7 @@ export function Layout() { ) : ( diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 93d445e0..50750aed 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -15,7 +15,6 @@ function lazyRouteComponent>( return LazyComponent } -const LandingPage = lazyRouteComponent(() => import('@/pages/landing'), 'LandingPage') const HomePage = lazyRouteComponent(() => import('@/pages/home'), 'HomePage') const LoginPage = lazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const RegisterPage = lazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') @@ -27,8 +26,12 @@ const MySkillsPage = lazyRouteComponent(() => import('@/pages/dashboard/my-skill const PublishPage = lazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') const MyNamespacesPage = lazyRouteComponent(() => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage') const NamespaceMembersPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage') +const NamespaceReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage') const ReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReviewDetailPage = lazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') +const PromotionsPage = lazyRouteComponent(() => import('@/pages/dashboard/promotions'), 'PromotionsPage') +const MyStarsPage = lazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') +const TokensPage = lazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') const DeviceAuthPage = lazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage') const SecuritySettingsPage = lazyRouteComponent(() => import('@/pages/settings/security'), 'SecuritySettingsPage') const AccountSettingsPage = lazyRouteComponent(() => import('@/pages/settings/accounts'), 'AccountSettingsPage') @@ -39,33 +42,48 @@ const rootRoute = createRootRoute({ component: Layout, }) -const homeRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: LandingPage, -}) +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 skillsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/skills', + path: 'skills', component: HomePage, }) const loginRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/login', + path: 'login', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), component: LoginPage, }) const registerRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/register', + path: 'register', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), component: RegisterPage, }) const searchRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/search', + path: 'search', component: SearchPage, validateSearch: (search: Record) => { return { @@ -78,147 +96,124 @@ const searchRoute = createRoute({ const namespaceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace', + path: '@$namespace', component: NamespacePage, }) const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace/$slug', + path: '@$namespace/$slug', 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 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 dashboardReviewDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews/$id', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) + path: 'dashboard/reviews/$id', + beforeLoad: requireAuth, + component: ReviewDetailPage, +}) + +const dashboardPromotionsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/promotions', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) + if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { + throw redirect({ to: '/dashboard' }) } return { user } }, - component: ReviewDetailPage, + component: PromotionsPage, +}) + +const dashboardStarsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/stars', + beforeLoad: requireAuth, + component: MyStarsPage, +}) + +const dashboardTokensRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/tokens', + beforeLoad: requireAuth, + component: TokensPage, }) const deviceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/device', + path: 'device', component: DeviceAuthPage, }) const settingsSecurityRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/settings/security', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'settings/security', + beforeLoad: requireAuth, component: SecuritySettingsPage, }) const settingsAccountsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/settings/accounts', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'settings/accounts', + beforeLoad: requireAuth, component: AccountSettingsPage, }) const adminUsersRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/users', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + path: 'admin/users', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -229,12 +224,9 @@ const adminUsersRoute = createRoute({ const adminAuditLogRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/audit-log', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + path: 'admin/audit-log', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -244,7 +236,6 @@ const adminAuditLogRoute = createRoute({ }) const routeTree = rootRoute.addChildren([ - homeRoute, skillsRoute, loginRoute, registerRoute, @@ -256,8 +247,12 @@ const routeTree = rootRoute.addChildren([ dashboardPublishRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, + dashboardNamespaceReviewsRoute, dashboardReviewsRoute, dashboardReviewDetailRoute, + dashboardPromotionsRoute, + dashboardStarsRoute, + dashboardTokensRoute, deviceRoute, settingsSecurityRoute, settingsAccountsRoute, diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index abc46b53..3f08b402 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) { @@ -79,3 +57,33 @@ export function useUpdateUserStatus() { }, }) } + +export function useApproveUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.approveUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useDisableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.disableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useEnableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.enableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts index 7a2a3649..e398aca4 100644 --- a/web/src/features/admin/use-audit-log.ts +++ b/web/src/features/admin/use-audit-log.ts @@ -1,15 +1,6 @@ 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 @@ -19,21 +10,14 @@ export interface AuditLogParams { } 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/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts index a96b3952..24244384 100644 --- a/web/src/features/auth/use-account-merge.ts +++ b/web/src/features/auth/use-account-merge.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query' import { accountApi } from '@/api/client' -import type { MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' +import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' export function useInitiateAccountMerge() { return useMutation({ @@ -13,3 +13,9 @@ export function useVerifyAccountMerge() { mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), }) } + +export function useConfirmAccountMerge() { + return useMutation({ + mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request), + }) +} diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts new file mode 100644 index 00000000..f7521a7d --- /dev/null +++ b/web/src/features/promotion/use-promotion-list.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { promotionApi } from '@/api/client' +import type { PromotionTask } from '@/api/types' + +export function usePromotionList(status = 'PENDING') { + return useQuery({ + queryKey: ['promotions', status], + queryFn: async () => { + const page = await promotionApi.list({ status }) + return page.items + }, + }) +} + +export function usePromotionDetail(id: number) { + return useQuery({ + queryKey: ['promotions', id], + queryFn: () => promotionApi.get(id), + enabled: !!id, + }) +} + +export function useApprovePromotion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.approve(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + }, + }) +} + +export function useRejectPromotion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.reject(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + }, + }) +} + +export type { PromotionTask } diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index aab7638f..5a70738b 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -1,29 +1,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' -import type { ReviewTask } from './use-review-list' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' async function getReviewDetail(taskId: number): Promise { - return fetchJson(`/api/v1/reviews/${taskId}`) + return reviewApi.get(taskId) } async function approveReview(taskId: number, comment?: string): Promise { - await fetchJson(`/api/v1/reviews/${taskId}/approve`, { - method: 'POST', - headers: getCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ comment }), - }) + await reviewApi.approve(taskId, comment) } async function rejectReview(taskId: number, comment: string): Promise { - await fetchJson(`/api/v1/reviews/${taskId}/reject`, { - method: 'POST', - headers: getCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ comment }), - }) + await reviewApi.reject(taskId, comment) } export function useReviewDetail(taskId: number) { diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts index a0cef6fd..c0e846fe 100644 --- a/web/src/features/review/use-review-list.ts +++ b/web/src/features/review/use-review-list.ts @@ -1,29 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJson } from '@/api/client' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' -export interface ReviewTask { - id: number - skillVersionId: number - skillName: string - skillSlug: string - namespace: string - version: string - status: 'PENDING' | 'APPROVED' | 'REJECTED' - submittedBy: string - submittedAt: string - reviewedBy?: string - reviewedAt?: string - comment?: string +async function getReviewList(status: string, namespaceId?: number): Promise { + const page = await reviewApi.list({ status, namespaceId }) + return page.items } -async function getReviewList(status?: string): Promise { - const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews' - return fetchJson(url) -} - -export function useReviewList(status?: string) { +export function useReviewList(status: string, namespaceId?: number) { return useQuery({ - queryKey: ['reviews', status], - queryFn: () => getReviewList(status), + queryKey: ['reviews', status, namespaceId], + queryFn: () => getReviewList(status, namespaceId), + enabled: namespaceId === undefined || namespaceId > 0, }) } diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 865ec220..b4b409a3 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -15,8 +15,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize, rehypeHighlight]} components={{ - // @ts-ignore - react-markdown types issue - div: ({ node, ...props }) =>
, + div: (props) =>
, }} > {content} diff --git a/web/src/features/social/rating-input.tsx b/web/src/features/social/rating-input.tsx index e4245d65..4239a40b 100644 --- a/web/src/features/social/rating-input.tsx +++ b/web/src/features/social/rating-input.tsx @@ -1,19 +1,26 @@ import { useState } from 'react' import { Star } from 'lucide-react' import { useUserRating, useRate } from './use-rating' +import { useAuth } from '@/features/auth/use-auth' interface RatingInputProps { skillId: number + onRequireLogin?: () => void } -export function RatingInput({ skillId }: RatingInputProps) { +export function RatingInput({ skillId, onRequireLogin }: RatingInputProps) { const { data: userRating, isLoading } = useUserRating(skillId) const rateMutation = useRate(skillId) + const { isAuthenticated } = useAuth() const [hoveredRating, setHoveredRating] = useState(null) - const currentRating = userRating?.rating || 0 + const currentRating = userRating?.rated ? userRating.score : 0 const handleRate = (rating: number) => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } rateMutation.mutate(rating) } diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx index 6424fddc..e6767d0a 100644 --- a/web/src/features/social/star-button.tsx +++ b/web/src/features/social/star-button.tsx @@ -1,16 +1,24 @@ import { Button } from '@/shared/ui/button' import { useStar, useToggleStar } from './use-star' import { Star } from 'lucide-react' +import { useAuth } from '@/features/auth/use-auth' interface StarButtonProps { skillId: number + starCount: number + onRequireLogin?: () => void } -export function StarButton({ skillId }: StarButtonProps) { +export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonProps) { const { data: starStatus, isLoading } = useStar(skillId) const toggleMutation = useToggleStar(skillId) + const { isAuthenticated } = useAuth() const handleToggle = () => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } if (starStatus) { toggleMutation.mutate(starStatus.starred) } @@ -28,7 +36,7 @@ export function StarButton({ skillId }: StarButtonProps) { disabled={toggleMutation.isPending} > - {starStatus.starred ? '已收藏' : '收藏'} ({starStatus.starCount}) + {starStatus.starred ? '已收藏' : '收藏'} ({starCount}) ) } diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts index b97d33e2..403c905c 100644 --- a/web/src/features/social/use-rating.ts +++ b/web/src/features/social/use-rating.ts @@ -2,12 +2,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { fetchJson, getCsrfHeaders } from '@/api/client' interface UserRating { - rating?: number - ratedAt?: string + score: number + rated: boolean } async function getUserRating(skillId: number): Promise { - return fetchJson(`/api/v1/skills/${skillId}/rating`) + try { + return await fetchJson(`/api/v1/skills/${skillId}/rating`) + } catch (error) { + if (error instanceof Error && error.message === 'HTTP 401') { + return { score: 0, rated: false } + } + throw error + } } async function rateSkill(skillId: number, rating: number): Promise { @@ -16,7 +23,7 @@ async function rateSkill(skillId: number, rating: number): Promise { headers: getCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ rating }), + body: JSON.stringify({ score: rating }), }) } diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index 89610107..3e9aed97 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -3,11 +3,18 @@ import { fetchJson, getCsrfHeaders } from '@/api/client' interface StarStatus { starred: boolean - starCount: number } async function getStarStatus(skillId: number): Promise { - return fetchJson(`/api/v1/skills/${skillId}/star`) + try { + const starred = await fetchJson(`/api/v1/skills/${skillId}/star`) + return { starred } + } catch (error) { + if (error instanceof Error && error.message === 'HTTP 401') { + return { starred: false } + } + throw error + } } async function toggleStar(skillId: number, starred: boolean): Promise { diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index a73492bb..1b2787d6 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -33,7 +33,7 @@ export function TokenList() { } } - const formatDate = (dateString: string | null) => { + const formatDate = (dateString?: string | null) => { if (!dateString) return '-' return new Date(dateString).toLocaleString('zh-CN') } diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index cca6a177..07fe92ef 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -40,12 +40,12 @@ export function AuditLogPage() {
{formatDate(log.timestamp)} {log.action} - {log.userId} - {log.username || '-'} + {log.userId || '-'} + {log.resourceType || '-'} {log.ipAddress || '-'} - {log.details || '-'} + {log.resourceId || '-'} ))} diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 099d6775..77dcee1c 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Label } from '@/shared/ui/label' -import { useAdminUsers, useUpdateUserRole, useUpdateUserStatus } from '@/features/admin/use-admin-users' +import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users' import type { AdminUser } from '@/features/admin/use-admin-users' export function AdminUsersPage() { @@ -41,7 +41,9 @@ export function AdminUsersPage() { }) const updateRoleMutation = useUpdateUserRole() - const updateStatusMutation = useUpdateUserStatus() + const approveUserMutation = useApproveUser() + const disableUserMutation = useDisableUser() + const enableUserMutation = useEnableUser() const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString('zh-CN') @@ -62,7 +64,7 @@ export function AdminUsersPage() { const confirmRoleChange = async () => { if (!selectedUser) return try { - await updateRoleMutation.mutateAsync({ userId: selectedUser.id, role: newRole }) + await updateRoleMutation.mutateAsync({ userId: selectedUser.userId, role: newRole }) setRoleDialogOpen(false) setSelectedUser(null) } catch (error) { @@ -73,8 +75,11 @@ export function AdminUsersPage() { const confirmStatusChange = async () => { if (!selectedUser) return try { - const newStatus = actionType === 'ban' ? 'DISABLED' : 'ACTIVE' - await updateStatusMutation.mutateAsync({ userId: selectedUser.id, status: newStatus }) + if (actionType === 'ban') { + await disableUserMutation.mutateAsync(selectedUser.userId) + } else { + await enableUserMutation.mutateAsync(selectedUser.userId) + } setConfirmDialogOpen(false) setSelectedUser(null) } catch (error) { @@ -100,6 +105,7 @@ export function AdminUsersPage() {
@@ -131,18 +137,20 @@ export function AdminUsersPage() { {data.items.map((user) => ( - + {user.username} - {user.email} + {user.email || '-'} - {user.status === 'ACTIVE' ? '活跃' : '已禁用'} + {user.status === 'ACTIVE' ? '活跃' : user.status === 'PENDING' ? '待审批' : '已禁用'} {user.platformRoles.join(', ')} @@ -156,6 +164,15 @@ export function AdminUsersPage() { > 修改角色 + {user.status === 'PENDING' && ( + + )} {user.status === 'ACTIVE' ? ( - diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index b4358bfb..768b413f 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' import { TokenList } from '@/features/token/token-list' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -41,7 +42,7 @@ export function DashboardPage() {
平台角色
- {user.platformRoles.map((role) => ( + {user.platformRoles.map((role: string) => ( +
+ +
收藏与评分
+ + 查看我的收藏 + +
+ +
访问凭证
+ + 打开 Token 页面 + +
+ +
审核与治理
+ + 查看提升审核 + +
+
+
) diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index d95d7e7b..833cf0fb 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -18,6 +18,11 @@ export function MyNamespacesPage() { navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug } }) } + const handleReviewsClick = (slug: string, e: React.MouseEvent) => { + e.stopPropagation() + navigate({ to: '/dashboard/namespaces/$slug/reviews', params: { slug } }) + } + if (isLoading) { return (
@@ -66,15 +71,24 @@ export function MyNamespacesPage() {
@{namespace.slug}
- {namespace.type === 'TEAM' && ( +
+ {namespace.type === 'TEAM' && ( + + )} - )} +
))} diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx new file mode 100644 index 00000000..c420b6c6 --- /dev/null +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -0,0 +1,65 @@ +import { useParams } from '@tanstack/react-router' +import { Card } from '@/shared/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' +import { useReviewList } from '@/features/review/use-review-list' + +function ReviewListSection({ namespaceId }: { namespaceId?: number }) { + const { data: pending } = useReviewList('PENDING', namespaceId) + const { data: approved } = useReviewList('APPROVED', namespaceId) + const { data: rejected } = useReviewList('REJECTED', namespaceId) + + const renderItems = (items?: typeof pending) => { + if (!items || items.length === 0) { + return 暂无审核记录 + } + return ( + + {items.map((review) => ( +
+
+
+
{review.namespace}/{review.skillSlug}
+
版本 {review.version}
+
+
{new Date(review.submittedAt).toLocaleString('zh-CN')}
+
+ {review.reviewComment ? ( +

{review.reviewComment}

+ ) : null} +
+ ))} +
+ ) + } + + return ( + + + 待审核 + 已通过 + 已拒绝 + + {renderItems(pending)} + {renderItems(approved)} + {renderItems(rejected)} + + ) +} + +export function NamespaceReviewsPage() { + const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' }) + const { data: namespace } = useNamespaceDetail(slug) + + return ( +
+
+

命名空间审核

+

+ {namespace ? `${namespace.displayName} 的审核任务` : '加载命名空间信息中'} +

+
+ +
+ ) +} diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx new file mode 100644 index 00000000..469047de --- /dev/null +++ b/web/src/pages/dashboard/promotions.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' + +function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { + const { data: items, isLoading } = usePromotionList(status) + const approveMutation = useApprovePromotion() + const rejectMutation = useRejectPromotion() + const [commentById, setCommentById] = useState>({}) + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return 暂无提升申请 + } + + return ( +
+ {items.map((item) => ( + +
+
+
{item.sourceNamespace}/{item.sourceSkillSlug}
+
+ {item.sourceVersion} {'->'} @{item.targetNamespace} +
+
+
{new Date(item.submittedAt).toLocaleString('zh-CN')}
+
+ {status === 'PENDING' ? ( + <> + setCommentById((prev) => ({ ...prev, [item.id]: event.target.value }))} + /> +
+ + +
+ + ) : item.reviewComment ? ( +

{item.reviewComment}

+ ) : null} +
+ ))} +
+ ) +} + +export function PromotionsPage() { + return ( +
+
+

提升审核

+

审核团队技能提升到全局空间的申请

+
+ + + 待审核 + 已通过 + 已拒绝 + + + + + +
+ ) +} diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 4631b846..3d74e10a 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -82,11 +82,7 @@ export function ReviewDetailPage() {
-
-
- -

{review.skillName}

-
+

{review.namespace}/{review.skillSlug}

@@ -115,7 +111,7 @@ export function ReviewDetailPage() {
-

{review.submittedBy}

+

{review.submittedByName || review.submittedBy}

@@ -125,7 +121,7 @@ export function ReviewDetailPage() { <>
-

{review.reviewedBy}

+

{review.reviewedByName || review.reviewedBy}

@@ -137,10 +133,10 @@ export function ReviewDetailPage() { )}
- {review.comment && ( + {review.reviewComment && (
-

{review.comment}

+

{review.reviewComment}

)} diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 11ef1144..edcd733d 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -72,10 +72,10 @@ export function ReviewsPage() { {review.version} - {review.submittedBy} + {review.submittedByName || review.submittedBy} {formatDate(review.submittedAt)} {status !== 'PENDING' && ( - {review.reviewedBy || '—'} + {review.reviewedByName || review.reviewedBy || '—'} )} {status !== 'PENDING' && ( diff --git a/web/src/pages/dashboard/stars.tsx b/web/src/pages/dashboard/stars.tsx new file mode 100644 index 00000000..02c4628a --- /dev/null +++ b/web/src/pages/dashboard/stars.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from '@tanstack/react-router' +import { SkillCard } from '@/features/skill/skill-card' +import { useMyStars } from '@/shared/hooks/use-skill-queries' +import { Card } from '@/shared/ui/card' + +export function MyStarsPage() { + const navigate = useNavigate() + const { data: skills, isLoading } = useMyStars() + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) + } + + return ( +
+
+

我的收藏

+

查看你标记过的技能

+
+ + {!skills || skills.length === 0 ? ( + 还没有收藏任何技能 + ) : ( +
+ {skills.map((skill) => ( + navigate({ to: '/@$namespace/$slug', params: { namespace: skill.namespace, slug: skill.slug } })} + /> + ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/dashboard/tokens.tsx b/web/src/pages/dashboard/tokens.tsx new file mode 100644 index 00000000..dea5f9ca --- /dev/null +++ b/web/src/pages/dashboard/tokens.tsx @@ -0,0 +1,13 @@ +import { TokenList } from '@/features/token/token-list' + +export function TokensPage() { + return ( +
+
+

Token 管理

+

管理 CLI 和 API 使用的访问凭证

+
+ +
+ ) +} diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 8c639e1c..f4fab058 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,4 +1,4 @@ -import { Link } from '@tanstack/react-router' +import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { LoginButton } from '@/features/auth/login-button' import { useLocalLogin } from '@/features/auth/use-local-auth' @@ -7,15 +7,19 @@ import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' export function LoginPage() { + const navigate = useNavigate() + const search = useSearch({ from: '/login' }) const loginMutation = useLocalLogin() const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + async function handleSubmit(event: React.FormEvent) { event.preventDefault() try { await loginMutation.mutateAsync({ username, password }) - window.location.href = '/dashboard' + await navigate({ to: returnTo }) } catch { // mutation state drives the error UI } @@ -73,7 +77,11 @@ export function LoginPage() {

还没有账号? {' '} - + 立即注册

diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index ec881f15..c6e79f62 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,21 +1,27 @@ -import { Link } from '@tanstack/react-router' +import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' +import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' export function RegisterPage() { + const navigate = useNavigate() + const search = useSearch({ from: '/register' }) const registerMutation = useLocalRegister() const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + async function handleSubmit(event: React.FormEvent) { event.preventDefault() try { await registerMutation.mutateAsync({ username, email, password }) - window.location.href = '/dashboard' + await navigate({ to: returnTo }) } catch { // mutation state drives the error UI } @@ -26,56 +32,76 @@ export function RegisterPage() { 创建账号 - 注册后会自动建立本地会话,可继续进入 Dashboard。 + 支持本地注册,也可以直接使用 OAuth 登录进入平台。 -
-
- - setUsername(event.target.value)} - placeholder="3-64 位字母、数字或下划线" - /> -
-
- - setEmail(event.target.value)} - placeholder="可选,用于后续账号识别" - /> -
-
- - setPassword(event.target.value)} - placeholder="至少 8 位,包含 3 种字符类型" - /> -
- {registerMutation.error ? ( -

{registerMutation.error.message}

- ) : null} - -

- 已有账号? - {' '} - - 返回登录 - -

-
+ + + 本地账号 + OAuth + + + +
+
+ + setUsername(event.target.value)} + placeholder="3-64 位字母、数字或下划线" + /> +
+
+ + setEmail(event.target.value)} + placeholder="可选,用于后续账号识别" + /> +
+
+ + setPassword(event.target.value)} + placeholder="至少 8 位,包含 3 种字符类型" + /> +
+ {registerMutation.error ? ( +

{registerMutation.error.message}

+ ) : null} + +

+ 已有账号? + {' '} + + 返回登录 + +

+
+
+ + +

+ 直接使用现有 OAuth 账户进入平台,无需再创建本地密码。 +

+ +
+
diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index d143fa32..41f711ce 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' @@ -12,6 +12,7 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() + const confirmMutation = useConfirmAccountMerge() async function handleInitiate(event: React.FormEvent) { event.preventDefault() @@ -34,12 +35,22 @@ export function AccountSettingsPage() { mergeRequestId: Number(mergeRequestId), verificationToken, }) - setStatusMessage('账号合并已完成') + setStatusMessage('验证成功,确认后将执行正式合并') } catch (error) { setStatusMessage(error instanceof Error ? error.message : '验证合并失败') } } + async function handleConfirm() { + setStatusMessage('') + try { + await confirmMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) }) + setStatusMessage('账号合并已完成') + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : '确认合并失败') + } + } + return (
@@ -68,7 +79,7 @@ export function AccountSettingsPage() { 验证并完成合并 - 发起后会返回一次性 token;在当前阶段直接复制该 token 完成验证。 + 先完成 token 验证,再单独确认执行数据迁移。
@@ -92,6 +103,11 @@ export function AccountSettingsPage() { {verifyMutation.isPending ? '验证中...' : '完成合并'}
+
+ +
{statusMessage ?

{statusMessage}

: null}
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index f49139e8..1dc4b995 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,7 +1,12 @@ -import { useParams } from '@tanstack/react-router' +import { useParams, useNavigate, useRouterState } from '@tanstack/react-router' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { InstallCommand } from '@/features/skill/install-command' +import { RatingInput } from '@/features/social/rating-input' +import { StarButton } from '@/features/social/star-button' +import { useAuth } from '@/features/auth/use-auth' +import { adminApi } from '@/api/client' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' import { Button } from '@/shared/ui/button' @@ -14,13 +19,48 @@ import { } from '@/shared/hooks/use-skill-queries' export function SkillDetailPage() { + const navigate = useNavigate() + const location = useRouterState({ select: (s) => s.location }) + const queryClient = useQueryClient() const { namespace, slug } = useParams({ from: '/@$namespace/$slug' }) + const { user, hasRole } = useAuth() const { data: skill, isLoading: isLoadingSkill } = useSkillDetail(namespace, slug) const { data: versions } = useSkillVersions(namespace, slug) const latestVersion = versions?.[0] const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version) const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version) + const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + + const refreshSkill = () => { + queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + } + + const hideMutation = useMutation({ + mutationFn: () => adminApi.hideSkill(skill!.id), + onSuccess: refreshSkill, + }) + + const unhideMutation = useMutation({ + mutationFn: () => adminApi.unhideSkill(skill!.id), + onSuccess: refreshSkill, + }) + + const yankMutation = useMutation({ + mutationFn: () => adminApi.yankVersion(latestVersion!.id), + onSuccess: refreshSkill, + }) + + const requireLogin = () => { + navigate({ + to: '/login', + search: { + returnTo: `${location.pathname}${location.searchStr}${location.hash}`, + }, + }) + } if (isLoadingSkill) { return ( @@ -138,10 +178,29 @@ export function SkillDetailPage() {
+
+
评分
+
+ {skill.ratingCount > 0 && skill.ratingAvg !== undefined ? `${skill.ratingAvg.toFixed(1)} / 5` : '暂无'} +
+
+ +
+
命名空间
+ +
+ +
+ + + {!user && ( +

登录后可以收藏和评分

+ )} +
{skill.latestVersion && ( @@ -161,6 +220,28 @@ export function SkillDetailPage() { 下载 + + {governanceVisible && ( + +
治理操作
+
+ {!skill.hidden ? ( + + ) : ( + + )} + {latestVersion && ( + + )} +
+
+ )}
) diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 70b32147..a5124c6b 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' -import { fetchJson, fetchText, getCsrfHeaders } from '@/api/client' +import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client' async function searchSkills(params: SearchParams): Promise> { const queryParams = new URLSearchParams() @@ -38,6 +38,10 @@ async function getMySkills(): Promise { return fetchJson('/api/v1/me/skills') } +async function getMyStars(): Promise { + return meApi.getStars() +} + async function getMyNamespaces(): Promise { const page = await fetchJson>('/api/v1/namespaces') return page.items @@ -111,6 +115,13 @@ export function useMySkills() { }) } +export function useMyStars() { + return useQuery({ + queryKey: ['skills', 'stars'], + queryFn: getMyStars, + }) +} + export function useMyNamespaces() { return useQuery({ queryKey: ['namespaces', 'my'],