merge: bring feature/project-init into main for beta3

# Conflicts:
#	scripts/smoke-test.sh
This commit is contained in:
vsxd 2026-03-13 11:45:56 +08:00
commit 84dd08503d
83 changed files with 6462 additions and 520 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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.

View file

@ -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
View 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
View file

@ -54,6 +54,8 @@ coverage/
# Temporary files
.tmp/
tmp/
__pycache__/
*.py[cod]
# Git worktrees
.worktrees/

View file

@ -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.

View file

@ -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
View file

@ -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.

View file

@ -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)"; \

View file

@ -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

View 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

View file

@ -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

View file

@ -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))

View file

@ -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);
}
}

View file

@ -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()
);
}

View file

@ -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) {}
}

View file

@ -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<>();

View file

@ -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"));
}

View file

@ -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()));
}
}

View file

@ -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);
}

View file

@ -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()));
}
}

View file

@ -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("\"", "\\\"") + "\"}";
}
}

View file

@ -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("\"", "\\\"") + "\"}";
}
}

View file

@ -64,6 +64,9 @@ public class SkillController extends BaseApiController {
detail.status(),
detail.downloadCount(),
detail.starCount(),
detail.ratingAvg(),
detail.ratingCount(),
detail.hidden(),
detail.latestVersion(),
namespace
);

View file

@ -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
) {
}

View file

@ -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
) {}

View file

@ -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);
}
}
}

View file

@ -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,

View file

@ -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}

View file

@ -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}

View file

@ -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())

View file

@ -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);
}
}

View file

@ -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"));
}
}

View file

@ -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",

View file

@ -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);

View file

@ -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);
}

View file

@ -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";

View file

@ -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"));

View file

@ -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);
}

View file

@ -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());
}
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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)
));
}
}

View file

@ -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);

View file

@ -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()
);

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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 }),
})
},
}

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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' }}
>

View file

@ -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,

View file

@ -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'] })
},
})
}

View file

@ -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) {

View file

@ -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),
})
}

View 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 }

View file

@ -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) {

View file

@ -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,
})
}

View file

@ -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}

View file

@ -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)
}

View file

@ -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>
)
}

View file

@ -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 }),
})
}

View file

@ -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> {

View file

@ -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')
}

View file

@ -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>
))}

View file

@ -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>

View file

@ -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>
)

View file

@ -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>
))}

View 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>
)
}

View 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>
)
}

View file

@ -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>

View file

@ -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">

View 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>
)
}

View 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>
)
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
)

View file

@ -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'],