mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
merge: bring feature/project-init into main for beta3
# Conflicts: # scripts/smoke-test.sh
This commit is contained in:
commit
84dd08503d
83 changed files with 6462 additions and 520 deletions
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
5
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
5
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
|
@ -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:
|
||||
|
|
|
|||
43
.github/workflows/validate-openapi.yml
vendored
Normal file
43
.github/workflows/validate-openapi.yml
vendored
Normal file
|
|
@ -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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -54,6 +54,8 @@ coverage/
|
|||
# Temporary files
|
||||
.tmp/
|
||||
tmp/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
214
LICENSE
214
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.
|
||||
|
|
|
|||
27
Makefile
27
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)"; \
|
||||
|
|
|
|||
29
README.md
29
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
|
||||
|
|
|
|||
46
scripts/check-openapi-generated.sh
Executable file
46
scripts/check-openapi-generated.sh
Executable file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Void> 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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MessageResponse> 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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ResolveVersionResponse> 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<Long, NamespaceRole> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MessageResponse> 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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PageResponse<AdminUserSummaryResponse>> listUsers(
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
List<AdminUserSummaryResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
|
||||
AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId);
|
||||
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,4 +34,14 @@ public class MeController extends BaseApiController {
|
|||
|
||||
return ok("response.success.read", mySkillAppService.listMySkills(principal.userId()));
|
||||
}
|
||||
|
||||
@GetMapping("/stars")
|
||||
public ApiResponse<List<SkillSummaryResponse>> listMyStars(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
|
||||
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PromotionResponseDto> submitPromotion(
|
||||
@RequestBody PromotionRequestDto request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<PromotionResponseDto> 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<String> 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<PromotionResponseDto> 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<String> 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<PageResponse<PromotionResponseDto>> listPromotions(
|
||||
@RequestParam(defaultValue = "PENDING") String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Set<String> 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<PromotionRequest> requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size));
|
||||
return ok("response.success.read", PageResponse.from(requests.map(this::toResponse)));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public ApiResponse<PageResponse<PromotionResponseDto>> 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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReviewTaskResponse> submitReview(
|
||||
@RequestBody ReviewTaskRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> 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<Long, NamespaceRole> userNsRoles) {
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> 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<Void> 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<PageResponse<ReviewTaskResponse>> 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<Long, NamespaceRole> userNsRoles) {
|
||||
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
|
||||
Page<ReviewTask> 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<ReviewTaskResponse> visibleItems = tasks.getContent().stream()
|
||||
.filter(task -> canViewReview(task, userId, namespaceRoles))
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
Page<ReviewTaskResponse> responsePage = new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size());
|
||||
return ok("response.success.read", PageResponse.from(responsePage));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(
|
||||
@RequestParam Long namespaceId,
|
||||
|
|
@ -180,4 +227,34 @@ public class ReviewController extends BaseApiController {
|
|||
task.getReviewedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean canViewReview(ReviewTask task, String userId, Map<Long, NamespaceRole> 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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ public class SkillController extends BaseApiController {
|
|||
detail.status(),
|
||||
detail.downloadCount(),
|
||||
detail.starCount(),
|
||||
detail.ratingAvg(),
|
||||
detail.ratingCount(),
|
||||
detail.hidden(),
|
||||
detail.latestVersion(),
|
||||
namespace
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String> platformRoles,
|
||||
String status,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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<AdminUserSummaryResponse> listUsers(String keyword, String status, int page, int size) {
|
||||
UserStatus userStatus = parseStatus(status);
|
||||
Page<UserAccount> users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size));
|
||||
List<AdminUserSummaryResponse> 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<UserRoleBinding> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillSummaryResponse> listMySkills(String userId) {
|
||||
|
|
@ -62,6 +67,50 @@ public class MySkillAppService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
public List<SkillSummaryResponse> listMyStars(String userId) {
|
||||
List<com.iflytek.skillhub.domain.social.SkillStar> stars = skillStarRepository.findByUserId(
|
||||
userId,
|
||||
PageRequest.of(0, 200)
|
||||
).getContent();
|
||||
|
||||
List<Long> skillIds = stars.stream()
|
||||
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, Skill> skillsById = skillIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillRepository.findByIdIn(skillIds).stream()
|
||||
.collect(Collectors.toMap(Skill::getId, Function.identity()));
|
||||
|
||||
List<Long> latestVersionIds = skillsById.values().stream()
|
||||
.map(Skill::getLatestVersionId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
|
||||
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
|
||||
|
||||
List<Long> namespaceIds = skillsById.values().stream()
|
||||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, String> 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<Long, SkillVersion> versionsById,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.<Long, NamespaceRole>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.<Long, NamespaceRole>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.<Long, NamespaceRole>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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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<UserRoleBinding, Long> {
|
||||
List<UserRoleBinding> findByUserId(String userId);
|
||||
List<UserRoleBinding> findByUserIdIn(Collection<String> userIds);
|
||||
long deleteByUserId(String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UserAccount> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ public interface ReviewTaskRepository {
|
|||
ReviewTask save(ReviewTask reviewTask);
|
||||
Optional<ReviewTask> findById(Long id);
|
||||
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
|
||||
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
void delete(ReviewTask reviewTask);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<UserAccount> findById(String id);
|
||||
List<UserAccount> findByIdIn(List<String> ids);
|
||||
Optional<UserAccount> findByEmailIgnoreCase(String email);
|
||||
Page<UserAccount> search(String keyword, UserStatus status, Pageable pageable);
|
||||
UserAccount save(UserAccount user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NamespaceMember> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Skill, Long> jpaDelegate;
|
||||
|
||||
public JpaSkillRepositoryAdapter(SkillJpaRepository delegate) {
|
||||
this.delegate = delegate;
|
||||
this.jpaDelegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Skill> findById(Long id) {
|
||||
return jpaDelegate.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findByIdIn(List<Long> ids) {
|
||||
return delegate.findByIdIn(ids);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findAll() {
|
||||
return jpaDelegate.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
|
||||
return delegate.findByNamespaceIdAndSlug(namespaceId, slug);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
|
||||
return delegate.findByNamespaceIdAndStatus(namespaceId, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Skill save(Skill skill) {
|
||||
return jpaDelegate.save(skill);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findByOwnerId(String ownerId) {
|
||||
return delegate.findByOwnerId(ownerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementDownloadCount(Long skillId) {
|
||||
delegate.incrementDownloadCount(skillId);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
|
|||
|
||||
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
|
||||
|
||||
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
|
|
|
|||
|
|
@ -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<UserAccount, String>, 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<UserAccount> search(@Param("keyword") String keyword,
|
||||
@Param("status") UserStatus status,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<User | null> {
|
||||
try {
|
||||
return await unwrap<User>(client.GET('/api/v1/auth/me') as never)
|
||||
const user = await unwrap<User>(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<OAuthProvider[]> {
|
||||
return unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers') as never)
|
||||
const providers = await unwrap<OAuthProvider[]>(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<User> {
|
||||
|
|
@ -160,10 +180,11 @@ export const authApi = {
|
|||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
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<void> {
|
||||
await fetchJson<void>('/api/v1/account/merge/confirm', {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
async getTokens(): Promise<ApiToken[]> {
|
||||
return unwrap<ApiToken[]>(client.GET('/api/v1/tokens') as never)
|
||||
const tokens = await unwrap<ApiToken[]>(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<CreateTokenResponse> {
|
||||
return unwrap<CreateTokenResponse>(client.POST('/api/v1/tokens', {
|
||||
const token = await unwrap<CreateTokenResponse>(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<void> {
|
||||
|
|
@ -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<ReviewTask> {
|
||||
return fetchJson<ReviewTask>(`/api/v1/reviews/${id}`)
|
||||
},
|
||||
|
||||
async approve(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/reviews/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
},
|
||||
|
||||
async reject(id: number, comment: string): Promise<void> {
|
||||
await fetchJson<void>(`/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<PromotionTask> {
|
||||
return fetchJson<PromotionTask>(`/api/v1/promotions/${id}`)
|
||||
},
|
||||
|
||||
async approve(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/promotions/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
},
|
||||
|
||||
async reject(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/promotions/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const meApi = {
|
||||
async getStars(): Promise<SkillSummary[]> {
|
||||
return fetchJson<SkillSummary[]>('/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<void> {
|
||||
await fetchJson<void>(`/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<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/users/${userId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
},
|
||||
|
||||
async approveUser(userId: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/users/${userId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async disableUser(userId: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/users/${userId}/disable`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async enableUser(userId: string): Promise<void> {
|
||||
await fetchJson<void>(`/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<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/skills/${skillId}/hide`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ reason }),
|
||||
})
|
||||
},
|
||||
|
||||
async unhideSkill(skillId: number): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/skills/${skillId}/unhide`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async yankVersion(versionId: number, reason?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/skills/versions/${versionId}/yank`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ reason }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
3961
web/src/api/generated/schema.d.ts
vendored
3961
web/src/api/generated/schema.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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<components['schemas']['AuthMeResponse'], 'userId' | 'displayName' | 'platformRoles'> & {
|
||||
userId: string
|
||||
displayName: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
oauthProvider?: string
|
||||
platformRoles: string[]
|
||||
}
|
||||
|
||||
export type OAuthProvider = Omit<components['schemas']['AuthProviderResponse'], 'id' | 'name' | 'authorizationUrl'> & {
|
||||
id: string
|
||||
name: string
|
||||
authorizationUrl: string
|
||||
}
|
||||
|
||||
export type ApiToken = Omit<components['schemas']['TokenSummaryResponse'], 'id' | 'name' | 'tokenPrefix' | 'createdAt'> & {
|
||||
id: number
|
||||
name: string
|
||||
tokenPrefix: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
lastUsedAt?: string
|
||||
}
|
||||
|
||||
export type CreateTokenRequest = Omit<components['schemas']['TokenCreateRequest'], 'name'> & {
|
||||
name: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export type CreateTokenResponse = Omit<components['schemas']['TokenCreateResponse'], 'token' | 'id' | 'name' | 'tokenPrefix' | 'createdAt'> & {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <LandingPage />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background bg-dots relative">
|
||||
|
|
@ -64,6 +71,7 @@ export function Layout() {
|
|||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ returnTo: '' }}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
activeProps={{ className: 'text-primary' }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ function lazyRouteComponent<TModule extends Record<string, unknown>>(
|
|||
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<string, unknown>) => ({
|
||||
returnTo: typeof search.returnTo === 'string' ? search.returnTo : '',
|
||||
}),
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
const registerRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/register',
|
||||
path: 'register',
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
returnTo: typeof search.returnTo === 'string' ? search.returnTo : '',
|
||||
}),
|
||||
component: RegisterPage,
|
||||
})
|
||||
|
||||
const searchRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/search',
|
||||
path: 'search',
|
||||
component: SearchPage,
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<PagedAdminUsers> {
|
||||
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<PagedAdminUsers>(url)
|
||||
return adminApi.getUsers(params)
|
||||
}
|
||||
|
||||
async function updateUserRole(userId: string, role: string): Promise<void> {
|
||||
await fetchJson<void>(`/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<void> {
|
||||
await fetchJson<void>(`/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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PagedAuditLogs> {
|
||||
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<PagedAuditLogs>(url)
|
||||
return adminApi.getAuditLogs(params)
|
||||
}
|
||||
|
||||
export function useAuditLog(params: AuditLogParams) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
43
web/src/features/promotion/use-promotion-list.ts
Normal file
43
web/src/features/promotion/use-promotion-list.ts
Normal file
|
|
@ -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 }
|
||||
|
|
@ -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<ReviewTask> {
|
||||
return fetchJson<ReviewTask>(`/api/v1/reviews/${taskId}`)
|
||||
return reviewApi.get(taskId)
|
||||
}
|
||||
|
||||
async function approveReview(taskId: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/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<void> {
|
||||
await fetchJson<void>(`/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) {
|
||||
|
|
|
|||
|
|
@ -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<ReviewTask[]> {
|
||||
const page = await reviewApi.list({ status, namespaceId })
|
||||
return page.items
|
||||
}
|
||||
|
||||
async function getReviewList(status?: string): Promise<ReviewTask[]> {
|
||||
const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews'
|
||||
return fetchJson<ReviewTask[]>(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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 className="prose prose-sm dark:prose-invert max-w-none" {...props} />,
|
||||
div: (props) => <div className="prose prose-sm dark:prose-invert max-w-none" {...props} />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null)
|
||||
|
||||
const currentRating = userRating?.rating || 0
|
||||
const currentRating = userRating?.rated ? userRating.score : 0
|
||||
|
||||
const handleRate = (rating: number) => {
|
||||
if (!isAuthenticated) {
|
||||
onRequireLogin?.()
|
||||
return
|
||||
}
|
||||
rateMutation.mutate(rating)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<Star className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
|
||||
{starStatus.starred ? '已收藏' : '收藏'} ({starStatus.starCount})
|
||||
{starStatus.starred ? '已收藏' : '收藏'} ({starCount})
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UserRating> {
|
||||
return fetchJson<UserRating>(`/api/v1/skills/${skillId}/rating`)
|
||||
try {
|
||||
return await fetchJson<UserRating>(`/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<void> {
|
||||
|
|
@ -16,7 +23,7 @@ async function rateSkill(skillId: number, rating: number): Promise<void> {
|
|||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ rating }),
|
||||
body: JSON.stringify({ score: rating }),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@ import { fetchJson, getCsrfHeaders } from '@/api/client'
|
|||
|
||||
interface StarStatus {
|
||||
starred: boolean
|
||||
starCount: number
|
||||
}
|
||||
|
||||
async function getStarStatus(skillId: number): Promise<StarStatus> {
|
||||
return fetchJson<StarStatus>(`/api/v1/skills/${skillId}/star`)
|
||||
try {
|
||||
const starred = await fetchJson<boolean>(`/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<void> {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ export function AuditLogPage() {
|
|||
<div className="flex gap-4">
|
||||
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
|
||||
<option value="">全部</option>
|
||||
<option value="USER_LOGIN">用户登录</option>
|
||||
<option value="USER_LOGOUT">用户登出</option>
|
||||
<option value="USER_ROLE_CHANGE">角色变更</option>
|
||||
<option value="USER_STATUS_CHANGE">状态变更</option>
|
||||
<option value="SKILL_PUBLISH">技能发布</option>
|
||||
<option value="SKILL_REVIEW">技能审核</option>
|
||||
<option value="CLI_PUBLISH">CLI 发布</option>
|
||||
<option value="COMPAT_PUBLISH">Compat 发布</option>
|
||||
<option value="REVIEW_APPROVE">审核通过</option>
|
||||
<option value="REVIEW_REJECT">审核拒绝</option>
|
||||
<option value="PROMOTION_APPROVE">提升通过</option>
|
||||
<option value="YANK_SKILL_VERSION">版本撤回</option>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="用户 ID..."
|
||||
|
|
@ -85,11 +85,11 @@ export function AuditLogPage() {
|
|||
<TableRow key={log.id}>
|
||||
<TableCell>{formatDate(log.timestamp)}</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell>{log.userId}</TableCell>
|
||||
<TableCell>{log.username || '-'}</TableCell>
|
||||
<TableCell>{log.userId || '-'}</TableCell>
|
||||
<TableCell>{log.resourceType || '-'}</TableCell>
|
||||
<TableCell>{log.ipAddress || '-'}</TableCell>
|
||||
<TableCell className="max-w-md truncate">
|
||||
{log.details || '-'}
|
||||
{log.resourceId || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">全部</option>
|
||||
<option value="ACTIVE">活跃</option>
|
||||
<option value="PENDING">待审批</option>
|
||||
<option value="DISABLED">已禁用</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -131,18 +137,20 @@ export function AdminUsersPage() {
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableRow key={user.userId}>
|
||||
<TableCell className="font-medium">{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.email || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border ${
|
||||
user.status === 'ACTIVE'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
: user.status === 'PENDING'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{user.status === 'ACTIVE' ? '活跃' : '已禁用'}
|
||||
{user.status === 'ACTIVE' ? '活跃' : user.status === 'PENDING' ? '待审批' : '已禁用'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{user.platformRoles.join(', ')}</TableCell>
|
||||
|
|
@ -156,6 +164,15 @@ export function AdminUsersPage() {
|
|||
>
|
||||
修改角色
|
||||
</Button>
|
||||
{user.status === 'PENDING' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => approveUserMutation.mutate(user.userId)}
|
||||
>
|
||||
审批通过
|
||||
</Button>
|
||||
)}
|
||||
{user.status === 'ACTIVE' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -251,7 +268,7 @@ export function AdminUsersPage() {
|
|||
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={confirmStatusChange} disabled={updateStatusMutation.isPending}>
|
||||
<Button onClick={confirmStatusChange} disabled={disableUserMutation.isPending || enableUserMutation.isPending}>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium font-heading">平台角色</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.platformRoles.map((role) => (
|
||||
{user.platformRoles.map((role: string) => (
|
||||
<span
|
||||
key={role}
|
||||
className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary border border-primary/20"
|
||||
|
|
@ -55,6 +56,27 @@ export function DashboardPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">收藏与评分</div>
|
||||
<Link to="/dashboard/stars" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
查看我的收藏
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">访问凭证</div>
|
||||
<Link to="/dashboard/tokens" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
打开 Token 页面
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">审核与治理</div>
|
||||
<Link to="/dashboard/promotions" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
查看提升审核
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TokenList />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -66,15 +71,24 @@ export function MyNamespacesPage() {
|
|||
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
{namespace.type === 'TEAM' && (
|
||||
<div className="flex gap-3">
|
||||
{namespace.type === 'TEAM' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => handleMembersClick(namespace.slug, e)}
|
||||
>
|
||||
管理成员
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => handleMembersClick(namespace.slug, e)}
|
||||
onClick={(e) => handleReviewsClick(namespace.slug, e)}
|
||||
>
|
||||
管理成员
|
||||
审核任务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
65
web/src/pages/dashboard/namespace-reviews.tsx
Normal file
65
web/src/pages/dashboard/namespace-reviews.tsx
Normal file
|
|
@ -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 <Card className="p-10 text-center text-muted-foreground">暂无审核记录</Card>
|
||||
}
|
||||
return (
|
||||
<Card className="divide-y divide-border/40">
|
||||
{items.map((review) => (
|
||||
<div key={review.id} className="p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold font-heading">{review.namespace}/{review.skillSlug}</div>
|
||||
<div className="text-sm text-muted-foreground">版本 {review.version}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{new Date(review.submittedAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
{review.reviewComment ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{review.reviewComment}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="PENDING">
|
||||
<TabsList>
|
||||
<TabsTrigger value="PENDING">待审核</TabsTrigger>
|
||||
<TabsTrigger value="APPROVED">已通过</TabsTrigger>
|
||||
<TabsTrigger value="REJECTED">已拒绝</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="PENDING" className="mt-6">{renderItems(pending)}</TabsContent>
|
||||
<TabsContent value="APPROVED" className="mt-6">{renderItems(approved)}</TabsContent>
|
||||
<TabsContent value="REJECTED" className="mt-6">{renderItems(rejected)}</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export function NamespaceReviewsPage() {
|
||||
const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' })
|
||||
const { data: namespace } = useNamespaceDetail(slug)
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">命名空间审核</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{namespace ? `${namespace.displayName} 的审核任务` : '加载命名空间信息中'}
|
||||
</p>
|
||||
</div>
|
||||
<ReviewListSection namespaceId={namespace?.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
web/src/pages/dashboard/promotions.tsx
Normal file
86
web/src/pages/dashboard/promotions.tsx
Normal file
|
|
@ -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<Record<number, string>>({})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-32 animate-shimmer rounded-xl" />
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return <Card className="p-10 text-center text-muted-foreground">暂无提升申请</Card>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold font-heading">{item.sourceNamespace}/{item.sourceSkillSlug}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.sourceVersion} {'->'} @{item.targetNamespace}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{new Date(item.submittedAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
{status === 'PENDING' ? (
|
||||
<>
|
||||
<Input
|
||||
placeholder="审核意见(可选)"
|
||||
value={commentById[item.id] ?? ''}
|
||||
onChange={(event) => setCommentById((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => approveMutation.mutate({ id: item.id, comment: commentById[item.id] })}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => rejectMutation.mutate({ id: item.id, comment: commentById[item.id] })}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : item.reviewComment ? (
|
||||
<p className="text-sm text-muted-foreground">{item.reviewComment}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromotionsPage() {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">提升审核</h1>
|
||||
<p className="text-muted-foreground text-lg">审核团队技能提升到全局空间的申请</p>
|
||||
</div>
|
||||
<Tabs defaultValue="PENDING">
|
||||
<TabsList>
|
||||
<TabsTrigger value="PENDING">待审核</TabsTrigger>
|
||||
<TabsTrigger value="APPROVED">已通过</TabsTrigger>
|
||||
<TabsTrigger value="REJECTED">已拒绝</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="PENDING" className="mt-6"><PromotionSection status="PENDING" /></TabsContent>
|
||||
<TabsContent value="APPROVED" className="mt-6"><PromotionSection status="APPROVED" /></TabsContent>
|
||||
<TabsContent value="REJECTED" className="mt-6"><PromotionSection status="REJECTED" /></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -82,11 +82,7 @@ export function ReviewDetailPage() {
|
|||
</div>
|
||||
|
||||
<Card className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">技能名称</Label>
|
||||
<p className="font-semibold font-heading">{review.skillName}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">命名空间/标识</Label>
|
||||
<p className="font-semibold font-mono">{review.namespace}/{review.skillSlug}</p>
|
||||
|
|
@ -115,7 +111,7 @@ export function ReviewDetailPage() {
|
|||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">提交者</Label>
|
||||
<p className="font-semibold">{review.submittedBy}</p>
|
||||
<p className="font-semibold">{review.submittedByName || review.submittedBy}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">提交时间</Label>
|
||||
|
|
@ -125,7 +121,7 @@ export function ReviewDetailPage() {
|
|||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">审核者</Label>
|
||||
<p className="font-semibold">{review.reviewedBy}</p>
|
||||
<p className="font-semibold">{review.reviewedByName || review.reviewedBy}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">审核时间</Label>
|
||||
|
|
@ -137,10 +133,10 @@ export function ReviewDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{review.comment && (
|
||||
{review.reviewComment && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">审核意见</Label>
|
||||
<p className="p-4 bg-secondary/50 rounded-xl text-sm leading-relaxed">{review.comment}</p>
|
||||
<p className="p-4 bg-secondary/50 rounded-xl text-sm leading-relaxed">{review.reviewComment}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ export function ReviewsPage() {
|
|||
{review.version}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{review.submittedBy}</TableCell>
|
||||
<TableCell>{review.submittedByName || review.submittedBy}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(review.submittedAt)}</TableCell>
|
||||
{status !== 'PENDING' && (
|
||||
<TableCell>{review.reviewedBy || '—'}</TableCell>
|
||||
<TableCell>{review.reviewedByName || review.reviewedBy || '—'}</TableCell>
|
||||
)}
|
||||
{status !== 'PENDING' && (
|
||||
<TableCell className="text-muted-foreground">
|
||||
|
|
|
|||
42
web/src/pages/dashboard/stars.tsx
Normal file
42
web/src/pages/dashboard/stars.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">我的收藏</h1>
|
||||
<p className="text-muted-foreground text-lg">查看你标记过的技能</p>
|
||||
</div>
|
||||
|
||||
{!skills || skills.length === 0 ? (
|
||||
<Card className="p-12 text-center text-muted-foreground">还没有收藏任何技能</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => navigate({ to: '/@$namespace/$slug', params: { namespace: skill.namespace, slug: skill.slug } })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
web/src/pages/dashboard/tokens.tsx
Normal file
13
web/src/pages/dashboard/tokens.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { TokenList } from '@/features/token/token-list'
|
||||
|
||||
export function TokensPage() {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">Token 管理</h1>
|
||||
<p className="text-muted-foreground text-lg">管理 CLI 和 API 使用的访问凭证</p>
|
||||
</div>
|
||||
<TokenList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HTMLFormElement>) {
|
||||
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() {
|
|||
<p className="text-center text-sm text-muted-foreground">
|
||||
还没有账号?
|
||||
{' '}
|
||||
<Link to="/register" className="font-medium text-primary hover:underline">
|
||||
<Link
|
||||
to="/register"
|
||||
search={{ returnTo }}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement>) {
|
||||
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() {
|
|||
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>创建账号</CardTitle>
|
||||
<CardDescription>注册后会自动建立本地会话,可继续进入 Dashboard。</CardDescription>
|
||||
<CardDescription>支持本地注册,也可以直接使用 OAuth 登录进入平台。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-username">用户名</label>
|
||||
<Input
|
||||
id="register-username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="3-64 位字母、数字或下划线"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-email">邮箱</label>
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="可选,用于后续账号识别"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-password">密码</label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="至少 8 位,包含 3 种字符类型"
|
||||
/>
|
||||
</div>
|
||||
{registerMutation.error ? (
|
||||
<p className="text-sm text-red-600">{registerMutation.error.message}</p>
|
||||
) : null}
|
||||
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
|
||||
{registerMutation.isPending ? '注册中...' : '注册并登录'}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
已有账号?
|
||||
{' '}
|
||||
<Link to="/login" className="font-medium text-primary hover:underline">
|
||||
返回登录
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
<Tabs defaultValue="local" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="local">本地账号</TabsTrigger>
|
||||
<TabsTrigger value="oauth">OAuth</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="local">
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-username">用户名</label>
|
||||
<Input
|
||||
id="register-username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="3-64 位字母、数字或下划线"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-email">邮箱</label>
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="可选,用于后续账号识别"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-password">密码</label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="至少 8 位,包含 3 种字符类型"
|
||||
/>
|
||||
</div>
|
||||
{registerMutation.error ? (
|
||||
<p className="text-sm text-red-600">{registerMutation.error.message}</p>
|
||||
) : null}
|
||||
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
|
||||
{registerMutation.isPending ? '注册中...' : '注册并登录'}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
已有账号?
|
||||
{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ returnTo }}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oauth" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
直接使用现有 OAuth 账户进入平台,无需再创建本地密码。
|
||||
</p>
|
||||
<LoginButton />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<Card className="glass-strong">
|
||||
|
|
@ -68,7 +79,7 @@ export function AccountSettingsPage() {
|
|||
<Card className="glass-strong">
|
||||
<CardHeader>
|
||||
<CardTitle>验证并完成合并</CardTitle>
|
||||
<CardDescription>发起后会返回一次性 token;在当前阶段直接复制该 token 完成验证。</CardDescription>
|
||||
<CardDescription>先完成 token 验证,再单独确认执行数据迁移。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleVerify}>
|
||||
|
|
@ -92,6 +103,11 @@ export function AccountSettingsPage() {
|
|||
{verifyMutation.isPending ? '验证中...' : '完成合并'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4">
|
||||
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !mergeRequestId}>
|
||||
{confirmMutation.isPending ? '确认中...' : '确认并完成合并'}
|
||||
</Button>
|
||||
</div>
|
||||
{statusMessage ? <p className="mt-4 text-sm text-muted-foreground">{statusMessage}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<div className="h-px bg-border/40" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">评分</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{skill.ratingCount > 0 && skill.ratingAvg !== undefined ? `${skill.ratingAvg.toFixed(1)} / 5` : '暂无'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">命名空间</div>
|
||||
<NamespaceBadge type="GLOBAL" name={`@${namespace}`} />
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
|
||||
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
|
||||
{!user && (
|
||||
<p className="text-xs text-muted-foreground">登录后可以收藏和评分</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{skill.latestVersion && (
|
||||
|
|
@ -161,6 +220,28 @@ export function SkillDetailPage() {
|
|||
</svg>
|
||||
下载
|
||||
</Button>
|
||||
|
||||
{governanceVisible && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">治理操作</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{!skill.hidden ? (
|
||||
<Button variant="outline" onClick={() => hideMutation.mutate()} disabled={hideMutation.isPending}>
|
||||
{hideMutation.isPending ? '处理中...' : '隐藏技能'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => unhideMutation.mutate()} disabled={unhideMutation.isPending}>
|
||||
{unhideMutation.isPending ? '处理中...' : '恢复技能'}
|
||||
</Button>
|
||||
)}
|
||||
{latestVersion && (
|
||||
<Button variant="destructive" onClick={() => yankMutation.mutate()} disabled={yankMutation.isPending}>
|
||||
{yankMutation.isPending ? '处理中...' : '撤回当前版本'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<PagedResponse<SkillSummary>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
|
@ -38,6 +38,10 @@ async function getMySkills(): Promise<SkillSummary[]> {
|
|||
return fetchJson<SkillSummary[]>('/api/v1/me/skills')
|
||||
}
|
||||
|
||||
async function getMyStars(): Promise<SkillSummary[]> {
|
||||
return meApi.getStars()
|
||||
}
|
||||
|
||||
async function getMyNamespaces(): Promise<Namespace[]> {
|
||||
const page = await fetchJson<PagedResponse<Namespace>>('/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'],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue