merge(main): sync latest origin/main into feature/project-review

This commit is contained in:
yun-zhi-ztl 2026-03-13 12:49:12 +08:00
commit 447a34a1d1
204 changed files with 19208 additions and 1473 deletions

18
.env.release.example Normal file
View file

@ -0,0 +1,18 @@
# `edge` tracks the latest build from the default branch.
# For deterministic environments, pin a release tag like `v0.1.0`.
SKILLHUB_VERSION=edge
SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server
SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web
POSTGRES_PORT=5432
POSTGRES_DB=skillhub
POSTGRES_USER=skillhub
POSTGRES_PASSWORD=skillhub_demo
REDIS_PORT=6379
API_PORT=8080
WEB_PORT=80
# Optional: configure real GitHub OAuth before exposing the stack to other users.
OAUTH2_GITHUB_CLIENT_ID=local-placeholder
OAUTH2_GITHUB_CLIENT_SECRET=local-placeholder

40
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Bug Report
description: Report a defect in SkillHub
title: "[Bug] "
labels:
- bug
body:
- type: textarea
id: summary
attributes:
label: Summary
description: What happened?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps To Reproduce
description: Include commands, requests, or UI flow
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Branch, commit, runtime profile, browser, OS, etc.
- type: textarea
id: api-impact
attributes:
label: API Contract Impact
description: If relevant, include the request path, response shape, and whether `web/src/api/generated/schema.d.ts` appears stale.
- type: textarea
id: logs
attributes:
label: Logs Or Screenshots

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security Report
url: https://github.com/iflytek/skillhub/security/advisories/new
about: Do not file public issues for suspected vulnerabilities.

View file

@ -0,0 +1,33 @@
name: Feature Request
description: Propose a new capability or workflow improvement
title: "[Feature] "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What user or operator problem does this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
- type: textarea
id: impact
attributes:
label: Impact
description: Auth, API, migration, deployment, observability, or UX impact
- type: textarea
id: contract
attributes:
label: Contract Or SDK Impact
description: Note whether this proposal changes OpenAPI, generated SDKs, CLI protocol, or operator docs.

29
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,29 @@
## Summary
- What changed?
- Why is this needed?
## Validation
- [ ] Backend tests passed
- [ ] Frontend typecheck/build passed
- [ ] OpenAPI SDK regenerated or checked when API contracts changed
- [ ] Smoke test run when relevant
Commands run:
```bash
# paste commands here
```
## Risk
- User-facing impact:
- Deployment or migration impact:
- Rollback approach:
## Notes
- Related issue:
- Follow-up work:
- Docs or operator runbooks updated when behavior changed:

81
.github/workflows/publish-images.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: Publish Images
on:
push:
branches:
- main
- feature/project-init
tags:
- "v*.*.*"
workflow_dispatch:
concurrency:
group: publish-images-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
DOCKER_PLATFORMS: linux/amd64,linux/arm64
jobs:
publish:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: server
context: ./server
dockerfile: ./server/Dockerfile
image: ghcr.io/${{ github.repository_owner }}/skillhub-server
- name: web
context: ./web
dockerfile: ./web/Dockerfile
image: ghcr.io/${{ github.repository_owner }}/skillhub-web
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image }}
tags: |
type=raw,value=edge,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push ${{ matrix.name }}
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: ${{ env.DOCKER_PLATFORMS }}
push: true
provenance: false
sbom: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}

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/

33
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,33 @@
# Code of Conduct
## Our Standard
Contributors and maintainers are expected to keep discussion technical,
respectful, and constructive.
Examples of expected behavior:
- Focus on the problem, tradeoffs, and evidence.
- Assume good intent, but challenge weak reasoning directly.
- Share actionable feedback.
- Respect different levels of experience and domain knowledge.
Examples of unacceptable behavior:
- Harassment, insults, or personal attacks
- Bad-faith argumentation or repeated hostility
- Publishing private or sensitive information without permission
- Disruptive behavior that blocks productive collaboration
## Enforcement
Project maintainers may remove comments, reject contributions, or restrict
participation for behavior that violates this code of conduct.
Serious or repeated violations may result in a temporary or permanent ban from
project spaces.
## Reporting
Report conduct issues privately to the maintainers through a private maintainer
channel. Do not use public issues for personal or sensitive reports.

81
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,81 @@
# Contributing to SkillHub
## Scope
SkillHub is a self-hosted registry for agent skills. Contributions should
preserve the existing architecture and product direction documented in
[`docs/`](./docs).
## Before You Start
- Read [`README.md`](./README.md) for local development commands.
- Check the relevant design docs before changing behavior.
- Open an issue for non-trivial changes before sending a large pull request.
## Development Setup
Prerequisites:
- Docker and Docker Compose
- Java 21
- Node.js and `pnpm`
Start the local stack:
```bash
make dev-all
```
Useful commands:
```bash
make test
make typecheck-web
make build-web
make generate-api
./scripts/check-openapi-generated.sh
./scripts/smoke-test.sh
```
Stop the stack:
```bash
make dev-all-down
```
## Change Guidelines
- Keep changes focused. Avoid mixing refactors with behavior changes.
- Follow existing module boundaries across `server/`, `web/`, and `docs/`.
- Add or update tests when behavior changes.
- Update docs when APIs, auth flows, deployment, or operator workflows change.
- Regenerate and commit `web/src/api/generated/schema.d.ts` when backend OpenAPI
contracts change.
- Prefer backward-compatible changes unless the issue explicitly allows a break.
## Pull Requests
Before opening a pull request, make sure:
- The branch is rebased or merged cleanly from the target branch.
- Relevant backend tests pass.
- Frontend typecheck/build passes when frontend files changed.
- `make generate-api` or `./scripts/check-openapi-generated.sh` has been run when
backend API contracts changed.
- Smoke coverage is updated when operator-facing workflows change.
- The pull request description explains motivation, scope, and rollout impact.
## Commit Style
Conventional-style subjects are preferred, for example:
- `feat(auth): add local account login`
- `fix(ops): align smoke test with csrf flow`
- `docs(deploy): clarify runtime image usage`
## Reporting Security Issues
Do not open public issues for suspected security vulnerabilities.
Use GitHub Security Advisories or your internal security process to report them
privately to the maintainers.

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

@ -14,9 +14,7 @@ help: ## 显示帮助
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
dev: ## 启动本地开发环境(仅依赖服务)
docker compose up -d
@echo "Waiting for services to be healthy..."
@sleep 5
docker compose up -d --wait --remove-orphans
@echo "Services ready."
@echo "Start backend with: make dev-server"
@echo "Start frontend with: make dev-web"
@ -28,13 +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 -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './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..."; \
@ -42,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)"; \
@ -71,15 +80,18 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
@echo "Local environment is ready:"
@echo " Web UI: $(DEV_WEB_URL)"
@echo " Backend: $(DEV_API_URL)"
@echo "Mock auth users:"
@echo " local-user -> X-Mock-User-Id: local-user"
@echo " local-admin -> X-Mock-User-Id: local-admin"
@echo "Logs:"
@echo " Backend: $(DEV_SERVER_LOG)"
@echo " Frontend: $(DEV_WEB_LOG)"
dev-server: ## 启动后端开发服务器
cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local
cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local'
dev-down: ## 停止本地开发环境
docker compose down
docker compose down --remove-orphans
dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
@ -89,7 +101,7 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动)
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
@$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID)
docker compose down -v
docker compose down -v --remove-orphans
rm -rf $(DEV_DIR)
@$(MAKE) dev-all
@ -127,8 +139,6 @@ lint-web: ## 前端代码检查
cd web && pnpm run lint
db-reset: ## 重置数据库
docker compose down -v
docker compose up -d postgres
@echo "Waiting for postgres..."
@sleep 3
docker compose down -v --remove-orphans
docker compose up -d --wait --remove-orphans postgres
cd server && ./mvnw flyway:migrate -pl skillhub-app

154
README.md
View file

@ -24,15 +24,19 @@ 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.
## Quick Start
Start the full local stack with: `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up`
### Prerequisites
- Docker & Docker Compose
@ -48,6 +52,13 @@ Then open:
- Web UI: `http://localhost:3000`
- Backend API: `http://localhost:8080`
Local profile seeds two mock-auth users automatically:
- `local-user` for normal publishing and namespace operations
- `local-admin` with `SUPER_ADMIN` for review and admin flows
Use them with the `X-Mock-User-Id` header in local development.
Stop everything with:
```bash
@ -62,6 +73,129 @@ 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.
This is the supported path for anyone who wants a ready-to-use local
environment without building the backend or frontend on their machine.
Published images target both `linux/amd64` and `linux/arm64`.
1. Copy the runtime environment template.
2. Pick an image tag.
3. Start the stack with Docker Compose.
```bash
cp .env.release.example .env.release
```
Recommended image tags:
- `SKILLHUB_VERSION=edge` for the latest `main` build
- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release
Start the runtime:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d
```
Then open:
- Web UI: `http://localhost`
- Backend API: `http://localhost:8080`
Stop it with:
```bash
docker compose --env-file .env.release -f compose.release.yml down
```
The runtime stack uses its own Compose project name, so it does not
collide with containers from `make dev-all`.
The runtime uses the existing `local,docker` profile combination so it
is immediately usable with the same mock-auth flow as local development.
Available seeded users:
- `local-user`
- `local-admin`
Pass `X-Mock-User-Id` to the backend when you need an authenticated
session without configuring GitHub OAuth. If the GHCR package remains
private, run `docker login ghcr.io` before `docker compose up -d`.
### Monitoring
The Phase 4 monitoring stack lives under [`monitoring/`](./monitoring).
It provides a local Prometheus + Grafana pair that scrapes the backend's
Actuator Prometheus endpoint.
Start it with:
```bash
cd monitoring
docker compose -f docker-compose.monitoring.yml up -d
```
Then open:
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3001` (`admin` / `admin`)
By default Prometheus scrapes `http://host.docker.internal:8080/actuator/prometheus`,
so start the backend locally on port `8080` first.
## Kubernetes
Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s):
- `configmap.yaml`
- `secret.yaml.example`
- `backend-deployment.yaml`
- `frontend-deployment.yaml`
- `services.yaml`
- `ingress.yaml`
Apply them after creating your own secret:
```bash
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/secret.yaml
kubectl apply -f deploy/k8s/backend-deployment.yaml
kubectl apply -f deploy/k8s/frontend-deployment.yaml
kubectl apply -f deploy/k8s/services.yaml
kubectl apply -f deploy/k8s/ingress.yaml
```
## Smoke Test
A lightweight smoke test script is available at [`scripts/smoke-test.sh`](./scripts/smoke-test.sh).
Run it against a local backend:
```bash
./scripts/smoke-test.sh http://localhost:8080
```
## Architecture
```
@ -81,9 +215,9 @@ Run `make help` to see all available commands.
┌────────────┼────────────┐
│ │ │
┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐
│PostgreSQL│ │ Redis │ │ MinIO
└──────────┘ └──────────┘ └────────┘
┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐
│PostgreSQL│ │ Redis │ │ Storage
└──────────┘ └──────────┘ └────────
```
## Contributing
@ -91,6 +225,12 @@ Run `make help` to see all available commands.
Contributions are welcome. Please open an issue first to discuss
what you'd like to change.
- Contribution guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md)
- Code of conduct: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md)
- Contribution guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md)
- Code of conduct: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md)
## License
MIT
Apache License 2.0

76
compose.release.yml Normal file
View file

@ -0,0 +1,76 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "${POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-skillhub}
POSTGRES_USER: ${POSTGRES_USER:-skillhub}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skillhub} -d ${POSTGRES_DB:-skillhub}"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
server:
image: ${SKILLHUB_SERVER_IMAGE:-ghcr.io/iflytek/skillhub-server}:${SKILLHUB_VERSION:-edge}
restart: unless-stopped
ports:
- "${API_PORT:-8080}:8080"
environment:
SPRING_PROFILES_ACTIVE: local,docker
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
STORAGE_BASE_PATH: /var/lib/skillhub/storage
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
volumes:
- skillhub_storage:/var/lib/skillhub/storage
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
web:
image: ${SKILLHUB_WEB_IMAGE:-ghcr.io/iflytek/skillhub-web}:${SKILLHUB_VERSION:-edge}
restart: unless-stopped
ports:
- "${WEB_PORT:-80}:80"
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
volumes:
postgres_data:
skillhub_storage:

View file

@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: skillhub-server
labels:
app.kubernetes.io/name: skillhub-server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: skillhub-server
template:
metadata:
labels:
app.kubernetes.io/name: skillhub-server
spec:
containers:
- name: server
image: ghcr.io/iflytek/skillhub-server:edge
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
env:
- name: SPRING_PROFILES_ACTIVE
value: docker
- name: SPRING_DATASOURCE_URL
valueFrom:
secretKeyRef:
name: skillhub-secret
key: spring-datasource-url
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: skillhub-secret
key: spring-datasource-username
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: skillhub-secret
key: spring-datasource-password
- name: SPRING_DATA_REDIS_HOST
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-host
- name: SPRING_DATA_REDIS_PORT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-port
- name: STORAGE_BASE_PATH
valueFrom:
configMapKeyRef:
name: skillhub-config
key: storage-base-path
- name: SESSION_COOKIE_SECURE
value: "true"
- name: OAUTH2_GITHUB_CLIENT_ID
valueFrom:
secretKeyRef:
name: skillhub-secret
key: oauth2-github-client-id
optional: true
- name: OAUTH2_GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: skillhub-secret
key: oauth2-github-client-secret
optional: true
volumeMounts:
- name: skillhub-storage
mountPath: /var/lib/skillhub/storage
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
initialDelaySeconds: 20
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
initialDelaySeconds: 30
periodSeconds: 15
volumes:
- name: skillhub-storage
persistentVolumeClaim:
claimName: skillhub-storage-pvc

19
deploy/k8s/configmap.yaml Normal file
View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: skillhub-config
data:
redis-host: redis
redis-port: "6379"
storage-base-path: /var/lib/skillhub/storage
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: skillhub-storage-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

View file

@ -0,0 +1,35 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: skillhub-web
labels:
app.kubernetes.io/name: skillhub-web
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: skillhub-web
template:
metadata:
labels:
app.kubernetes.io/name: skillhub-web
spec:
containers:
- name: web
image: ghcr.io/iflytek/skillhub-web:edge
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
name: http
readinessProbe:
httpGet:
path: /nginx-health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /nginx-health
port: http
initialDelaySeconds: 10
periodSeconds: 15

26
deploy/k8s/ingress.yaml Normal file
View file

@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: skillhub
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 100m
spec:
ingressClassName: nginx
rules:
- host: skills.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: skillhub-server
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: skillhub-web
port:
number: 80

View file

@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: skillhub-secret
type: Opaque
stringData:
spring-datasource-url: jdbc:postgresql://postgres:5432/skillhub
spring-datasource-username: skillhub
spring-datasource-password: change-me
oauth2-github-client-id: your-client-id
oauth2-github-client-secret: your-client-secret

27
deploy/k8s/services.yaml Normal file
View file

@ -0,0 +1,27 @@
apiVersion: v1
kind: Service
metadata:
name: skillhub-server
labels:
app.kubernetes.io/name: skillhub-server
spec:
selector:
app.kubernetes.io/name: skillhub-server
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: skillhub-web
labels:
app.kubernetes.io/name: skillhub-web
spec:
selector:
app.kubernetes.io/name: skillhub-web
ports:
- name: http
port: 80
targetPort: http

View file

@ -1,86 +0,0 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: skillhub
POSTGRES_USER: skillhub
POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U skillhub"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
server:
build:
context: ./server
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub
SPRING_DATASOURCE_USERNAME: skillhub
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID}
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
web:
build:
context: ./web
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
server:
condition: service_healthy
volumes:
postgres_data:
minio_data:

View file

@ -114,6 +114,9 @@ skillhub/
│ ├── Dockerfile # 前端多阶段构建
│ └── nginx.conf # Nginx 配置SPA 路由 + API 反向代理)
├── docker-compose.yml # 本地开发依赖服务PostgreSQL/Redis/MinIO
├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis
├── .env.release.example # 单机运行时环境变量模板
├── .github/workflows/ # GitHub Actions 镜像发布流程
├── Makefile # 顶层开发编排dev / dev-all / build
├── docs/ # 设计文档
└── README.md
@ -123,10 +126,20 @@ skillhub/
## 8. 部署架构
同域部署,统一入口:
- `https://skills.example.com/` → 前端静态资源
- `https://skills.example.com/api/*` → 反向代理到 Spring Boot
- 生产环境通过 Nginx 或网关统一接入
部署模型收敛为两条路径:
- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。
- 交付路径GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。
- 发布镜像为多架构 manifest至少覆盖 `linux/amd64``linux/arm64`
单机运行时统一入口:
- `http://localhost/` → Web 容器Nginx
- `http://localhost/api/*` → Web 容器反向代理到 Spring Boot
- `http://localhost:8080/actuator/health` → 后端健康检查
单机运行时使用 `local,docker` profile 组合:
- `local` 提供 mock 登录和种子账号,保证拉起即用
- `docker` 负责将数据库、Redis 地址切换到 Compose 网络
## 9. 分布式环境要求
@ -148,3 +161,5 @@ skillhub/
- 缓存/SessionSpring Session + Redis
- 数据库迁移Flyway
- 认证Spring Security OAuth2 Client一期 GitHub
- 镜像发布GitHub Actions 推送至 GHCR默认维护 `edge` 与语义化版本标签
- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest

View file

@ -1,350 +1,179 @@
# skillhub 部署架构与运维
## 1 K8s 部署拓扑
## 1 运行模型
当前仓库只保留两种运行方式:
- 开发环境:`make dev-all`
- 前端和后端运行在宿主机
- `docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO
- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d`
- 前端和后端都运行在容器内
- 使用 GitHub Actions 发布到 GHCR 的镜像
- 默认发布 `linux/amd64``linux/arm64` 多架构镜像
- PostgreSQL、Redis 与应用容器一起通过 Compose 启动
不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`
## 2 单机交付拓扑
```
┌─────────────┐
│ Ingress │
│ (Nginx) │
└──────┬──────┘
┌────────────┴────────────┐
│ /api/* │ /*
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Spring Boot │ │ Nginx / CDN │
│ replicas: 2+ │ │ 静态资源 │
└────────┬─────────┘ └──────────────────┘
┌────────┴──────────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────────┐
│ PostgreSQL│ │ Redis │ │ S3 / MinIO │
│ (主从) │ │ │ │ │
└────────┘ └────────┘ └──────────────┘
┌──────────────┐
│ Browser / CLI│
└──────┬───────┘
┌──────────────┐
│ Web/Nginx │ published image
└──────┬───────┘
│ /api/*
┌──────────────┐
│ Spring Boot │ published image
└───┬────┬─────┘
│ │
▼ ▼
PostgreSQL Redis
```
## 2 服务配置
说明:
- Web 容器提供静态资源,并将 `/api/*``/oauth2/*``/.well-known/*` 反代到后端
- 后端运行 `local,docker` profile 组合
- 技能包文件默认落在容器卷 `skillhub_storage`,保证单机环境开箱即用
- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3
- 健康检查:`/actuator/health`liveness + readiness 分离)
- 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s`
- JVM`-XX:MaxRAMPercentage=75.0`
## 3 Profile 约定
## 3 环境 Profile
| Profile | 用途 | 特点 |
| Profile | 用途 | 说明 |
|---------|------|------|
| `local` | 本地开发 | Docker Compose 一键启动PostgreSQL/Redis/MinIOMock OAuth见下方说明 |
| `dev` | 开发环境 | 共享基础设施GitHub OAuth 测试应用 |
| `staging` | 预发布 | 与生产同构 |
| `prod` | 生产 | 多 Pod完整基础设施 |
| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 |
| `docker` | 容器网络适配 | 将数据库和 Redis 地址切换到 Compose 内网 |
### 本地开发 Mock 登录
单机交付环境使用 `SPRING_PROFILES_ACTIVE=local,docker`,原因很明确:
`local` profile 下提供两种开发登录方式:
- 这是当前唯一能保证“镜像拉起后直接可用”的 profile 组合
- 用户无需先配置 GitHub OAuth先用 mock 身份即可浏览和联调主要流程
- 后续如果引入专用 `runtime` / `demo` profile可以替换这层组合但当前方案不再新增第三条部署路径
1. **MockAuthFilter**(默认):通过 `X-Mock-User-Id` Header 模拟登录,自动创建 Session无需真实 OAuth 流程
2. **GitHub OAuth 测试应用**:配置 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` 后可走真实 OAuth 流程GitHub 支持 `http://localhost` 回调)
默认可用账号:
MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。
- `local-user`
- `local-admin`
### Docker Compose 说明
鉴权方式:
当前推荐的本地启动入口是 `make dev-all`。Docker Compose 在当前项目里主要承担本地依赖服务启动。
- 向后端请求携带 `X-Mock-User-Id: local-user`
- 或 `X-Mock-User-Id: local-admin`
## 4 开发环境
开发入口保持不变:
```bash
make dev-all
```
行为:
- `docker-compose.yml` 启动 PostgreSQL、Redis、MinIO
- `server` 在宿主机通过 Maven Wrapper 启动
- `web` 在宿主机通过 Vite 启动
常用命令:
```bash
make dev
make dev-all
make dev-down
make dev-all-down
make dev-all-reset
```
#### docker-compose.yml — 本地开发(仅依赖服务)
## 5 单机交付环境
本地开发时前后端在宿主机运行Docker Compose 只拉起依赖服务:
```yaml
# docker-compose.yml项目根目录
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: skillhub
POSTGRES_USER: skillhub
POSTGRES_PASSWORD: skillhub_dev
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001" # MinIO Console
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
volumes:
postgres_data:
minio_data:
```
生产环境文档不再提供 Compose 一键部署入口。当前仓库只保留本地开发所需的 `docker-compose.yml`,正式部署以镜像构建 + K8s 编排为准。
#### 前后端 Dockerfile
后端 Dockerfile`server/Dockerfile`
```dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY skillhub-app/pom.xml skillhub-app/
COPY skillhub-domain/pom.xml skillhub-domain/
COPY skillhub-auth/pom.xml skillhub-auth/
COPY skillhub-search/pom.xml skillhub-search/
COPY skillhub-storage/pom.xml skillhub-storage/
COPY skillhub-infra/pom.xml skillhub-infra/
RUN mvn dependency:go-offline -B
COPY . .
RUN mvn package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/skillhub-app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
```
前端 Dockerfile`web/Dockerfile`
```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
前端 Nginx 配置(`web/nginx.conf`
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA 路由回退
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理到后端
location /api/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# OAuth2 回调反向代理
location /oauth2/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /login/oauth2/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Well-known 发现端点
location /.well-known/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
}
}
```
### Spring Boot 配置文件分层
```
server/skillhub-app/src/main/resources/
├── application.yml # 公共配置(所有 profile 共享)
├── application-local.yml # 本地开发Docker Compose 服务地址)
├── application-dev.yml # 开发环境
├── application-staging.yml # 预发布
└── application-prod.yml # 生产
```
`application.yml`(公共配置):
```yaml
spring:
application:
name: skillhub
jpa:
open-in-view: false
hibernate:
ddl-auto: validate # 由 Flyway 管理 schemaHibernate 仅校验
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
locations: classpath:db/migration
server:
shutdown: graceful
spring.lifecycle.timeout-per-shutdown-phase: 30s
```
`application-local.yml`(本地开发,对应 Docker Compose
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/skillhub
username: skillhub
password: skillhub_dev
data:
redis:
host: localhost
port: 6379
jpa:
show-sql: true
skillhub:
storage:
type: s3
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket: skillhub
region: us-east-1
access-policy:
mode: OPEN # 本地开发默认开放准入
```
`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入):
```yaml
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
jpa:
show-sql: false
skillhub:
storage:
type: s3
endpoint: ${S3_ENDPOINT}
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
bucket: ${S3_BUCKET:skillhub}
region: ${S3_REGION:us-east-1}
```
### 本地开发启动流程
### 5.1 启动
```bash
# 一键启动依赖 + 后端 + 前端
make dev-all
cp .env.release.example .env.release
docker compose --env-file .env.release -f compose.release.yml up -d
```
启动后可直接访问
默认访问地址:
- Web UI: `http://localhost:3000`
- Web UI: `http://localhost`
- Backend API: `http://localhost:8080`
停止:
### 5.2 关键文件
```bash
make dev-all-down
```
- `compose.release.yml`
- 使用发布镜像,不在用户机器上执行本地构建
- 负责拉起 PostgreSQL、Redis、server、web
- 使用独立 Compose project name避免与开发环境容器互相污染
- `.env.release.example`
- 运行时变量模板
- 包含镜像名、镜像版本、端口和数据库凭证
如需分步启动:
### 5.3 镜像标签约定
```bash
make dev # 仅依赖服务
make dev-server # 仅后端
make dev-web # 仅前端
```
- `edge`
- `main` 分支最新构建
- 用于内部持续验证
- `vX.Y.Z`
- 对应 Git tag
- 用于稳定版本交付
- `latest`
- 仅在语义化版本 tag 发布时更新
### Makefile 命令
推荐:
```bash
make dev # 仅启动本地依赖服务
make dev-all # 一键启动本地依赖 + 后端 + 前端
make dev-down # 停止本地依赖服务
make dev-all-down # 停止本地依赖 + 后端 + 前端
make build # 构建后端
make generate-api # 生成 OpenAPI 类型
```
- 团队内部试用:`SKILLHUB_VERSION=edge`
- 对外演示或文档引用:固定为某个 `vX.Y.Z`
## 4 配置管理
## 6 GitHub Actions 发布流程
- 敏感配置K8s Secret数据库/Redis/S3 凭证、OAuth2 Client ID/Secret
- 非敏感配置K8s ConfigMap文件大小限制、Session TTL 等)
发布工作流文件:`.github/workflows/publish-images.yml`
## 5 可观测性
触发条件:
- push 到 `main`
- push 语义化版本 tag例如 `v1.2.0`
- 手动 `workflow_dispatch`
流程:
1. 检出代码
2. 登录 GHCR
3. 分别构建 `server/Dockerfile``web/Dockerfile`
4. 推送镜像:
- `ghcr.io/iflytek/skillhub-server`
- `ghcr.io/iflytek/skillhub-web`
5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签
6. 同时发布 `linux/amd64``linux/arm64` manifest避免 Apple Silicon / ARM 主机依赖模拟层
## 7 配置管理
开发环境:
- 本地命令与 `docker-compose.yml`
- 非敏感默认值可直接落库或写入本地配置
单机交付环境:
- 使用 `.env.release` 管理 Compose 变量
- 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io`
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
## 8 可观测性
| 维度 | 方案 |
|------|------|
| 日志 | JSON 格式 stdout包含 traceId/requestId |
| 指标 | Actuator + Micrometer → Prometheus |
| 链路追踪 | 一期 requestId 透传,后续接 Jaeger/Zipkin |
| 告警 | 基于 Prometheus5xx 率、延迟 P99、Pod 重启) |
| 健康检查 | `web/nginx-health``server/actuator/health` |
| 日志 | 容器 stdout / stderr |
| 指标 | Spring Boot Actuator后续可接 Prometheus |
requestId 透传Ingress 注入 → Spring Filter 读取放入 MDC → 日志自动携带 → 响应 Header 回传。
## 9 数据迁移
## 6 构建与发布
Flyway 仍是唯一 schema 变更入口:
### CI Pipeline 构建
```
代码提交 → CI Pipeline
├── server: mvn package → JAR
└── web: pnpm build → dist/
Docker 多阶段构建
├── server → eclipse-temurin:21-jre-alpine
└── web → nginx:alpine
推送镜像 → K8s 滚动更新
```
Makefile 顶层命令:`make dev`, `make dev-all`, `make dev-down`, `make dev-all-down`, `make build`, `make generate-api`
## 7 数据库迁移
Flyway 管理 schema 变更:
- 脚本路径:`server/skillhub-app/src/main/resources/db/migration/`
- 路径:`server/skillhub-app/src/main/resources/db/migration/`
- 命名:`V{version}__{description}.sql`
- 多 Pod 安全Flyway 自带数据库锁
- 启动策略:应用容器启动时自动执行迁移

View file

@ -0,0 +1,17 @@
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
grafana:
image: grafana/grafana:latest
ports:
- "3001:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
depends_on:
- prometheus

10
monitoring/prometheus.yml Normal file
View file

@ -0,0 +1,10 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: skillhub-backend
metrics_path: /actuator/prometheus
static_configs:
- targets:
- host.docker.internal:8080

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "homepage-redesign",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

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

143
scripts/dev_process.py Normal file
View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
import argparse
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
def is_running(pid: int) -> bool:
try:
os.kill(pid, 0)
except OSError:
return False
return True
def read_pid(pid_file: Path) -> int | None:
if not pid_file.exists():
return None
content = pid_file.read_text(encoding="utf-8").strip()
if not content:
return None
try:
return int(content)
except ValueError:
return None
def write_pid(pid_file: Path, pid: int) -> None:
pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(f"{pid}\n", encoding="utf-8")
def start_process(args: argparse.Namespace) -> int:
pid_file = Path(args.pid_file)
log_file = Path(args.log_file)
cwd = Path(args.cwd)
existing_pid = read_pid(pid_file)
if existing_pid and is_running(existing_pid):
print(existing_pid)
return 0
pid_file.unlink(missing_ok=True)
log_file.parent.mkdir(parents=True, exist_ok=True)
command = list(args.command)
if command and command[0] == "--":
command = command[1:]
with log_file.open("ab") as log_handle, open(os.devnull, "rb") as devnull:
process = subprocess.Popen(
command,
cwd=cwd,
stdin=devnull,
stdout=log_handle,
stderr=subprocess.STDOUT,
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
def stop_process(args: argparse.Namespace) -> int:
pid_file = Path(args.pid_file)
pid = read_pid(pid_file)
if not pid:
return 0
if not is_running(pid):
pid_file.unlink(missing_ok=True)
return 0
os.kill(pid, signal.SIGTERM)
deadline = time.time() + args.timeout
while time.time() < deadline:
if not is_running(pid):
pid_file.unlink(missing_ok=True)
return 0
time.sleep(0.2)
os.kill(pid, signal.SIGKILL)
pid_file.unlink(missing_ok=True)
return 0
def status_process(args: argparse.Namespace) -> int:
pid_file = Path(args.pid_file)
pid = read_pid(pid_file)
if not pid:
return 1
if not is_running(pid):
pid_file.unlink(missing_ok=True)
return 1
print(pid)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage detached dev processes.")
subparsers = parser.add_subparsers(dest="action", required=True)
start_parser = subparsers.add_parser("start")
start_parser.add_argument("--pid-file", required=True)
start_parser.add_argument("--log-file", required=True)
start_parser.add_argument("--cwd", required=True)
start_parser.add_argument("command", nargs=argparse.REMAINDER)
stop_parser = subparsers.add_parser("stop")
stop_parser.add_argument("--pid-file", required=True)
stop_parser.add_argument("--timeout", type=float, default=10.0)
status_parser = subparsers.add_parser("status")
status_parser.add_argument("--pid-file", required=True)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.action == "start":
if not args.command:
parser.error("start requires a command")
return start_process(args)
if args.action == "stop":
return stop_process(args)
if args.action == "status":
return status_process(args)
return 1
if __name__ == "__main__":
sys.exit(main())

174
scripts/runtime.sh Normal file
View file

@ -0,0 +1,174 @@
#!/bin/sh
set -eu
COMMAND="up"
if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then
COMMAND="$1"
shift
fi
SKILLHUB_REF="${SKILLHUB_REF:-main}"
SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime"
SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}"
SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}"
SKILLHUB_SERVER_IMAGE_VALUE="${SKILLHUB_SERVER_IMAGE:-}"
SKILLHUB_WEB_IMAGE_VALUE="${SKILLHUB_WEB_IMAGE:-}"
while [ "$#" -gt 0 ]; do
case "$1" in
--version)
[ "$#" -ge 2 ] || { echo "Missing value for --version" >&2; exit 1; }
SKILLHUB_VERSION_VALUE="$2"
shift 2
;;
--home)
[ "$#" -ge 2 ] || { echo "Missing value for --home" >&2; exit 1; }
SKILLHUB_HOME="$2"
shift 2
;;
--ref)
[ "$#" -ge 2 ] || { echo "Missing value for --ref" >&2; exit 1; }
SKILLHUB_REF="$2"
shift 2
;;
--server-image)
[ "$#" -ge 2 ] || { echo "Missing value for --server-image" >&2; exit 1; }
SKILLHUB_SERVER_IMAGE_VALUE="$2"
shift 2
;;
--web-image)
[ "$#" -ge 2 ] || { echo "Missing value for --web-image" >&2; exit 1; }
SKILLHUB_WEB_IMAGE_VALUE="$2"
shift 2
;;
--help|-h)
cat <<EOF
Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]
Options:
--version <tag> Use a specific image tag, for example v0.1.0
--home <dir> Store runtime files in a specific directory
--ref <git-ref> Download runtime files from a specific Git ref
--server-image <img> Override backend image repository
--web-image <img> Override frontend image repository
EOF
exit 0
;;
*)
echo "Unsupported argument: $1" >&2
exit 1
;;
esac
done
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml"
ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example"
ENV_FILE="$SKILLHUB_HOME/.env.release"
find_compose() {
if docker compose version >/dev/null 2>&1; then
echo "docker compose"
return 0
fi
if command -v docker-compose >/dev/null 2>&1; then
echo "docker-compose"
return 0
fi
echo "Docker Compose is required." >&2
exit 1
}
download_file() {
src="$1"
dest="$2"
tmp="$dest.tmp"
curl -fsSL "$src" -o "$tmp"
mv "$tmp" "$dest"
}
set_env_value() {
key="$1"
value="$2"
if [ ! -f "$ENV_FILE" ]; then
return 0
fi
tmp="$ENV_FILE.tmp"
if grep -q "^$key=" "$ENV_FILE"; then
sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp"
else
cat "$ENV_FILE" >"$tmp"
printf '%s=%s\n' "$key" "$value" >>"$tmp"
fi
mv "$tmp" "$ENV_FILE"
}
prepare_runtime_files() {
mkdir -p "$SKILLHUB_HOME"
download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE"
download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE"
if [ ! -f "$ENV_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
fi
if [ -n "$SKILLHUB_VERSION_VALUE" ]; then
set_env_value "SKILLHUB_VERSION" "$SKILLHUB_VERSION_VALUE"
fi
if [ -n "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then
set_env_value "SKILLHUB_SERVER_IMAGE" "$SKILLHUB_SERVER_IMAGE_VALUE"
fi
if [ -n "$SKILLHUB_WEB_IMAGE_VALUE" ]; then
set_env_value "SKILLHUB_WEB_IMAGE" "$SKILLHUB_WEB_IMAGE_VALUE"
fi
}
run_compose() {
compose_cmd="$(find_compose)"
# shellcheck disable=SC2086
$compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
prepare_runtime_files
case "$COMMAND" in
up)
run_compose up -d
cat <<EOF
SkillHub runtime started.
Web UI: http://localhost
Backend API: http://localhost:8080
Runtime dir: $SKILLHUB_HOME
Stop with:
curl -fsSL $SKILLHUB_RAW_BASE/scripts/runtime.sh | sh -s -- down
EOF
;;
down)
run_compose down
;;
clean)
run_compose down
rm -rf "$SKILLHUB_HOME"
;;
ps)
run_compose ps
;;
logs)
run_compose logs -f
;;
pull)
run_compose pull
;;
*)
echo "Unsupported command: $COMMAND" >&2
echo "Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]" >&2
exit 1
;;
esac

110
scripts/smoke-test.sh Executable file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
PASS=0
FAIL=0
COOKIE_JAR="$(mktemp)"
USERNAME="smoketest_$(date +%s)"
EMAIL="${USERNAME}@example.com"
PASSWORD="Smoke@2026"
NEW_PASSWORD="Smoke@2027"
cleanup() {
rm -f "$COOKIE_JAR"
}
trap cleanup EXIT
check() {
local desc="$1"
local url="$2"
local expected="$3"
local status
status="$(curl --retry 3 --retry-delay 1 --max-time 10 -s -o /dev/null -w "%{http_code}" "$url" || true)"
if [[ "$status" == "$expected" ]]; then
echo "PASS: $desc (HTTP $status)"
PASS=$((PASS + 1))
else
echo "FAIL: $desc (expected $expected, got $status)"
FAIL=$((FAIL + 1))
fi
}
echo "=== SkillHub Smoke Test ==="
echo "Target: $BASE_URL"
echo
check "Health endpoint" "$BASE_URL/actuator/health" "200"
check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200"
check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200"
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)"
REGISTER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/register" \
-b "$COOKIE_JAR" \
-c "$COOKIE_JAR" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"email\":\"$EMAIL\"}" || true)"
if [[ "$REGISTER_STATUS" == "200" ]]; then
echo "PASS: Register (HTTP $REGISTER_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Register (got $REGISTER_STATUS)"
FAIL=$((FAIL + 1))
fi
AUTH_ME_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)"
if [[ "$AUTH_ME_STATUS" == "200" ]]; then
echo "PASS: Auth me with session (HTTP $AUTH_ME_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Auth me with session (got $AUTH_ME_STATUS)"
FAIL=$((FAIL + 1))
fi
CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/change-password" \
-b "$COOKIE_JAR" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"currentPassword\":\"$PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}" || true)"
if [[ "$CHANGE_PASSWORD_STATUS" == "200" ]]; then
echo "PASS: Change password (HTTP $CHANGE_PASSWORD_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Change password (got $CHANGE_PASSWORD_STATUS)"
FAIL=$((FAIL + 1))
fi
LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/logout" \
-b "$COOKIE_JAR" \
-c "$COOKIE_JAR" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" || true)"
if [[ "$LOGOUT_STATUS" == "302" || "$LOGOUT_STATUS" == "200" || "$LOGOUT_STATUS" == "204" ]]; then
echo "PASS: Logout (HTTP $LOGOUT_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Logout (got $LOGOUT_STATUS)"
FAIL=$((FAIL + 1))
fi
POST_LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)"
if [[ "$POST_LOGOUT_STATUS" == "401" ]]; then
echo "PASS: Auth me after logout (HTTP $POST_LOGOUT_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Auth me after logout (got $POST_LOGOUT_STATUS)"
FAIL=$((FAIL + 1))
fi
echo
echo "Results: $PASS passed, $FAIL failed"
if [[ "$FAIL" -ne 0 ]]; then
exit 1
fi

View file

@ -14,7 +14,7 @@
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.1.0-beta.2</version>
<packaging>pom</packaging>
<properties>

View file

@ -8,7 +8,7 @@
<parent>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.1.0-beta.2</version>
</parent>
<artifactId>skillhub-app</artifactId>
@ -22,6 +22,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>

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

@ -0,0 +1,108 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("local")
public class LocalDevDataInitializer implements ApplicationRunner {
public static final String LOCAL_USER_ID = "local-user";
public static final String LOCAL_ADMIN_ID = "local-admin";
private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class);
private final UserAccountRepository userAccountRepository;
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final RoleRepository roleRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
public LocalDevDataInitializer(UserAccountRepository userAccountRepository,
NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
RoleRepository roleRepository,
UserRoleBindingRepository userRoleBindingRepository) {
this.userAccountRepository = userAccountRepository;
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.roleRepository = roleRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
UserAccount localUser = ensureUser(
LOCAL_USER_ID,
"Local Developer",
"local-user@example.test"
);
UserAccount localAdmin = ensureUser(
LOCAL_ADMIN_ID,
"Local Admin",
"local-admin@example.test"
);
Namespace globalNamespace = namespaceRepository.findBySlug("global")
.orElseThrow(() -> new IllegalStateException("Missing built-in global namespace"));
ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER);
ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER);
ensureRole(localAdmin.getId(), "SUPER_ADMIN");
log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID);
}
private UserAccount ensureUser(String userId, String displayName, String email) {
return userAccountRepository.findById(userId)
.map(existing -> {
existing.setDisplayName(displayName);
existing.setEmail(email);
existing.setStatus(UserStatus.ACTIVE);
return userAccountRepository.save(existing);
})
.orElseGet(() -> userAccountRepository.save(
new UserAccount(userId, displayName, email, null)
));
}
private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) {
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.orElseGet(() -> new NamespaceMember(namespaceId, userId, role));
if (member.getRole() != role) {
member.setRole(role);
}
namespaceMemberRepository.save(member);
}
private void ensureRole(String userId, String roleCode) {
boolean exists = userRoleBindingRepository.findByUserId(userId).stream()
.map(binding -> binding.getRole().getCode())
.anyMatch(roleCode::equals);
if (exists) {
return;
}
Role role = roleRepository.findByCode(roleCode)
.orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode));
userRoleBindingRepository.save(new UserRoleBinding(userId, role));
}
}

View file

@ -1,15 +1,28 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSkillItem;
import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
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.service.SkillSearchAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@ -19,10 +32,23 @@ 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, SkillSearchAppService skillSearchAppService) {
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")
@ -56,12 +82,63 @@ public class ClawHubCompatController {
@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);
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
coord.namespace(),
coord.slug(),
"latest".equals(version) ? null : version,
"latest".equals(version) ? "latest" : null,
null,
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
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,
HttpServletRequest request) throws IOException {
SkillPublishService.PublishResult 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

@ -15,8 +15,15 @@ public class DomainBeanConfig {
}
@Bean
public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) {
return new SkillPackageValidator(skillMetadataParser);
public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser,
SkillPublishProperties skillPublishProperties) {
return new SkillPackageValidator(
skillMetadataParser,
skillPublishProperties.getMaxFileCount(),
skillPublishProperties.getMaxSingleFileSize(),
skillPublishProperties.getMaxPackageSize(),
skillPublishProperties.getAllowedFileExtensions()
);
}
@Bean

View file

@ -17,7 +17,7 @@ public class OpenApiConfig {
.info(new Info()
.title("SkillHub API")
.description("Skills Registry Platform")
.version("0.1.0"))
.version("0.1.0-beta.2"))
.servers(List.of(
new Server().url("http://localhost:8080").description("Local development")
));

View file

@ -0,0 +1,53 @@
package com.iflytek.skillhub.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.Set;
@Component
@ConfigurationProperties(prefix = "skillhub.publish")
public class SkillPublishProperties {
private int maxFileCount = 100;
private long maxSingleFileSize = 1024 * 1024;
private long maxPackageSize = 100 * 1024 * 1024;
private Set<String> allowedFileExtensions = new LinkedHashSet<>(Set.of(
".md", ".txt", ".json", ".yaml", ".yml",
".js", ".ts", ".py", ".sh",
".png", ".jpg", ".svg"
));
public int getMaxFileCount() {
return maxFileCount;
}
public void setMaxFileCount(int maxFileCount) {
this.maxFileCount = maxFileCount;
}
public long getMaxSingleFileSize() {
return maxSingleFileSize;
}
public void setMaxSingleFileSize(long maxSingleFileSize) {
this.maxSingleFileSize = maxSingleFileSize;
}
public long getMaxPackageSize() {
return maxPackageSize;
}
public void setMaxPackageSize(long maxPackageSize) {
this.maxPackageSize = maxPackageSize;
}
public Set<String> getAllowedFileExtensions() {
return allowedFileExtensions;
}
public void setAllowedFileExtensions(Set<String> allowedFileExtensions) {
this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions);
}
}

View file

@ -0,0 +1,71 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.merge.AccountMergeService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MergeInitiateRequest;
import com.iflytek.skillhub.dto.MergeInitiateResponse;
import com.iflytek.skillhub.dto.MergeVerifyRequest;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/account/merge")
public class AccountMergeController extends BaseApiController {
private final AccountMergeService accountMergeService;
public AccountMergeController(ApiResponseFactory responseFactory,
AccountMergeService accountMergeService) {
super(responseFactory);
this.accountMergeService = accountMergeService;
}
@PostMapping("/initiate")
public ApiResponse<MergeInitiateResponse> initiate(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody MergeInitiateRequest request) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
var result = accountMergeService.initiate(principal.userId(), request.secondaryIdentifier());
return ok("response.success.created", new MergeInitiateResponse(
result.mergeRequestId(),
result.secondaryUserId(),
result.verificationToken(),
result.expiresAt().toString()
));
}
@PostMapping("/verify")
public ApiResponse<MessageResponse> verify(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody MergeVerifyRequest request) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
accountMergeService.verify(
principal.userId(),
request.mergeRequestId(),
request.verificationToken()
);
return ok("response.success.updated", new MessageResponse("Account merge verified"));
}
@PostMapping("/confirm")
public ApiResponse<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

@ -2,12 +2,15 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
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 org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException;
@ -16,6 +19,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/cli")
@ -23,13 +27,16 @@ public class CliController extends BaseApiController {
private final SkillPackageValidator skillPackageValidator;
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
private final SkillQueryService skillQueryService;
public CliController(ApiResponseFactory responseFactory,
SkillPackageValidator skillPackageValidator,
SkillPackageArchiveExtractor skillPackageArchiveExtractor) {
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
SkillQueryService skillQueryService) {
super(responseFactory);
this.skillPackageValidator = skillPackageValidator;
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
this.skillQueryService = skillQueryService;
}
@GetMapping("/whoami")
@ -66,4 +73,33 @@ 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) Map<Long, NamespaceRole> userNsRoles) {
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
namespace,
slug,
version,
tag,
hash,
userId,
userNsRoles != null ? userNsRoles : 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()
));
}
}

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

@ -0,0 +1,87 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuthMeResponse;
import com.iflytek.skillhub.dto.ChangePasswordRequest;
import com.iflytek.skillhub.dto.LocalLoginRequest;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/auth/local")
public class LocalAuthController extends BaseApiController {
private final LocalAuthService localAuthService;
private final SkillHubMetrics skillHubMetrics;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
}
@PostMapping("/register")
public ApiResponse<AuthMeResponse> register(@Valid @RequestBody LocalRegisterRequest request,
HttpServletRequest httpRequest) {
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
skillHubMetrics.incrementUserRegister();
establishSession(principal, httpRequest);
return ok("response.success.created", AuthMeResponse.from(principal));
}
@PostMapping("/login")
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
HttpServletRequest httpRequest) {
PlatformPrincipal principal;
try {
principal = localAuthService.login(request.username(), request.password());
} catch (RuntimeException ex) {
skillHubMetrics.recordLocalLogin(false);
throw ex;
}
skillHubMetrics.recordLocalLogin(true);
establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
}
@PostMapping("/change-password")
public ApiResponse<Void> changePassword(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody ChangePasswordRequest request) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword());
return ok("response.success.updated", null);
}
private void establishSession(PlatformPrincipal principal, HttpServletRequest request) {
var authorities = principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
var authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute("platformPrincipal", principal);
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
}

View file

@ -0,0 +1,76 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import com.iflytek.skillhub.dto.AdminSkillMutationResponse;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/admin/skills")
public class AdminSkillController extends BaseApiController {
private final SkillGovernanceService skillGovernanceService;
public AdminSkillController(ApiResponseFactory responseFactory,
SkillGovernanceService skillGovernanceService) {
super(responseFactory);
this.skillGovernanceService = skillGovernanceService;
}
@PostMapping("/{skillId}/hide")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminSkillMutationResponse> hideSkill(@PathVariable Long skillId,
@RequestBody(required = false) AdminSkillActionRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
var skill = skillGovernanceService.hideSkill(
skillId,
principal.userId(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
request != null ? request.reason() : null
);
return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "HIDE", skill.getStatus().name()));
}
@PostMapping("/{skillId}/unhide")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminSkillMutationResponse> unhideSkill(@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
var skill = skillGovernanceService.unhideSkill(
skillId,
principal.userId(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")
);
return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "UNHIDE", skill.getStatus().name()));
}
@PostMapping("/versions/{versionId}/yank")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminSkillMutationResponse> yankVersion(@PathVariable Long versionId,
@RequestBody(required = false) AdminSkillActionRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
var version = skillGovernanceService.yankVersion(
versionId,
principal.userId(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
request != null ? request.reason() : null
);
return ok("response.success.updated", new AdminSkillMutationResponse(version.getSkillId(), versionId, "YANK", version.getStatus().name()));
}
}

View file

@ -54,4 +54,22 @@ public class UserManagementController extends BaseApiController {
@Valid @RequestBody AdminUserStatusUpdateRequest request) {
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status()));
}
@PostMapping("/{userId}/approve")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> approveUser(@PathVariable String userId) {
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
}
@PostMapping("/{userId}/disable")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> disableUser(@PathVariable String userId) {
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED"));
}
@PostMapping("/{userId}/enable")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
}
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
@ -9,7 +10,10 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PublishResponse;
import com.iflytek.skillhub.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;
@ -22,13 +26,19 @@ public class CliPublishController extends BaseApiController {
private final SkillPublishService skillPublishService;
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
private final SkillHubMetrics skillHubMetrics;
private final AuditLogService auditLogService;
public CliPublishController(SkillPublishService skillPublishService,
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
ApiResponseFactory responseFactory) {
ApiResponseFactory responseFactory,
SkillHubMetrics skillHubMetrics,
AuditLogService auditLogService) {
super(responseFactory);
this.skillPublishService = skillPublishService;
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
this.skillHubMetrics = skillHubMetrics;
this.auditLogService = auditLogService;
}
@PostMapping("/publish")
@ -37,7 +47,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());
@ -64,6 +75,17 @@ public class CliPublishController extends BaseApiController {
publishResult.version().getFileCount(),
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,25 +2,40 @@ 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;
import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.PromotionService;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.*;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionActionRequest;
import com.iflytek.skillhub.dto.PromotionRequestDto;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Set;
@ -36,7 +51,7 @@ public class PromotionController extends BaseApiController {
private final NamespaceRepository namespaceRepository;
private final UserAccountRepository userAccountRepository;
private final RbacService rbacService;
private final ReviewPermissionChecker permissionChecker;
private final AuditLogService auditLogService;
public PromotionController(PromotionService promotionService,
PromotionRequestRepository promotionRequestRepository,
@ -45,7 +60,7 @@ public class PromotionController extends BaseApiController {
NamespaceRepository namespaceRepository,
UserAccountRepository userAccountRepository,
RbacService rbacService,
ReviewPermissionChecker permissionChecker,
AuditLogService auditLogService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.promotionService = promotionService;
@ -55,50 +70,74 @@ public class PromotionController extends BaseApiController {
this.namespaceRepository = namespaceRepository;
this.userAccountRepository = userAccountRepository;
this.rbacService = rbacService;
this.permissionChecker = permissionChecker;
this.auditLogService = auditLogService;
}
@PostMapping
public ApiResponse<PromotionResponseDto> submitPromotion(
@RequestBody PromotionRequestDto request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
public ApiResponse<PromotionResponseDto> submitPromotion(@RequestBody PromotionRequestDto request,
@RequestAttribute("userId") String userId,
@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());
return ok("response.success.create", toResponse(promotion));
request.sourceSkillId(),
request.sourceVersionId(),
request.targetNamespaceId(),
userId,
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)
);
recordAudit(
"PROMOTION_SUBMIT",
userId,
promotion.getId(),
httpRequest,
"{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"
);
return ok("response.success.created", toResponse(promotion));
}
@PostMapping("/{id}/approve")
public ApiResponse<PromotionResponseDto> approvePromotion(
@PathVariable Long id,
@RequestBody(required = false) PromotionActionRequest request,
@RequestAttribute("userId") String userId) {
public ApiResponse<PromotionResponseDto> approvePromotion(@PathVariable Long id,
@RequestBody(required = false) PromotionActionRequest request,
@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);
PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, rbacService.getUserRoleCodes(userId));
recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(promotion));
}
@PostMapping("/{id}/reject")
public ApiResponse<PromotionResponseDto> rejectPromotion(
@PathVariable Long id,
@RequestBody(required = false) PromotionActionRequest request,
@RequestAttribute("userId") String userId) {
public ApiResponse<PromotionResponseDto> rejectPromotion(@PathVariable Long id,
@RequestBody(required = false) PromotionActionRequest request,
@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);
PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, rbacService.getUserRoleCodes(userId));
recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(promotion));
}
@GetMapping("/pending")
public ApiResponse<PageResponse<PromotionResponseDto>> listPendingPromotions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
@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);
if (!permissionChecker.canListPendingPromotions(platformRoles)) {
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
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,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
throw new DomainForbiddenException("promotion.no_permission");
}
Page<PromotionRequest> requests = promotionRequestRepository.findByStatus(
@ -107,47 +146,73 @@ public class PromotionController extends BaseApiController {
}
@GetMapping("/{id}")
public ApiResponse<PromotionResponseDto> getPromotionDetail(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow();
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
if (!permissionChecker.canReadPromotion(promotion, userId, platformRoles)) {
public ApiResponse<PromotionResponseDto> getPromotionDetail(@PathVariable Long id,
@RequestAttribute("userId") String userId) {
PromotionRequest promotion = promotionRequestRepository.findById(id)
.orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id));
if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) {
throw new DomainForbiddenException("promotion.no_permission");
}
return ok("response.success.read", toResponse(promotion));
}
private PromotionResponseDto toResponse(PromotionRequest req) {
Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()).orElseThrow();
SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()).orElseThrow();
Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()).orElseThrow();
Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()).orElseThrow();
private PromotionResponseDto toResponse(PromotionRequest request) {
Skill sourceSkill = skillRepository.findById(request.getSourceSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId()));
Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId()));
Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId()));
String submittedByName = userAccountRepository.findById(req.getSubmittedBy())
.map(UserAccount::getDisplayName).orElse(null);
String reviewedByName = req.getReviewedBy() != null
? userAccountRepository.findById(req.getReviewedBy())
.map(UserAccount::getDisplayName).orElse(null)
String submittedByName = userAccountRepository.findById(request.getSubmittedBy())
.map(UserAccount::getDisplayName)
.orElse(null);
String reviewedByName = request.getReviewedBy() != null
? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null)
: null;
return new PromotionResponseDto(
req.getId(),
req.getSourceSkillId(),
sourceNs.getSlug(),
request.getId(),
request.getSourceSkillId(),
sourceNamespace.getSlug(),
sourceSkill.getSlug(),
sourceVersion.getVersion(),
targetNs.getSlug(),
req.getTargetSkillId(),
req.getStatus().name(),
req.getSubmittedBy(),
targetNamespace.getSlug(),
request.getTargetSkillId(),
request.getStatus().name(),
request.getSubmittedBy(),
submittedByName,
req.getReviewedBy(),
request.getReviewedBy(),
reviewedByName,
req.getReviewComment(),
req.getSubmittedAt(),
req.getReviewedAt()
request.getReviewComment(),
request.getSubmittedAt(),
request.getReviewedAt()
);
}
private void recordAudit(String action,
String userId,
Long targetId,
HttpServletRequest httpRequest,
String detailJson) {
auditLogService.record(
userId,
action,
"PROMOTION_REQUEST",
targetId,
MDC.get("requestId"),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
detailJson
);
}
private String detailWithComment(String comment) {
if (comment == null || comment.isBlank()) {
return null;
}
return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}";
}
}

View file

@ -2,25 +2,41 @@ 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;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.*;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewActionRequest;
import com.iflytek.skillhub.dto.ReviewTaskRequest;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Set;
@ -36,7 +52,7 @@ public class ReviewController extends BaseApiController {
private final NamespaceRepository namespaceRepository;
private final UserAccountRepository userAccountRepository;
private final RbacService rbacService;
private final ReviewPermissionChecker permissionChecker;
private final AuditLogService auditLogService;
public ReviewController(ReviewService reviewService,
ReviewTaskRepository reviewTaskRepository,
@ -45,7 +61,7 @@ public class ReviewController extends BaseApiController {
NamespaceRepository namespaceRepository,
UserAccountRepository userAccountRepository,
RbacService rbacService,
ReviewPermissionChecker permissionChecker,
AuditLogService auditLogService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.reviewService = reviewService;
@ -55,71 +71,125 @@ public class ReviewController extends BaseApiController {
this.namespaceRepository = namespaceRepository;
this.userAccountRepository = userAccountRepository;
this.rbacService = rbacService;
this.permissionChecker = permissionChecker;
this.auditLogService = auditLogService;
}
@PostMapping
public ApiResponse<ReviewTaskResponse> submitReview(
@RequestBody ReviewTaskRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
public ApiResponse<ReviewTaskResponse> submitReview(@RequestBody ReviewTaskRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
ReviewTask task = reviewService.submitReview(
request.skillVersionId(),
userId,
userNsRoles != null ? userNsRoles : Map.of()
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)
);
return ok("response.success.create", toResponse(task));
recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}");
return ok("response.success.created", toResponse(task));
}
@PostMapping("/{id}/approve")
public ApiResponse<ReviewTaskResponse> approveReview(
@PathVariable Long id,
@RequestBody(required = false) ReviewActionRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
public ApiResponse<ReviewTaskResponse> approveReview(@PathVariable Long id,
@RequestBody(required = false) ReviewActionRequest request,
@RequestAttribute("userId") String userId,
@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);
ReviewTask task = reviewService.approveReview(
id,
userId,
comment,
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)
);
recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(task));
}
@PostMapping("/{id}/reject")
public ApiResponse<ReviewTaskResponse> rejectReview(
@PathVariable Long id,
@RequestBody(required = false) ReviewActionRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
public ApiResponse<ReviewTaskResponse> rejectReview(@PathVariable Long id,
@RequestBody(required = false) ReviewActionRequest request,
@RequestAttribute("userId") String userId,
@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);
ReviewTask task = reviewService.rejectReview(
id,
userId,
comment,
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)
);
recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(task));
}
@PostMapping("/{id}/withdraw")
public ApiResponse<Void> withdrawReview(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
ReviewTask task = reviewTaskRepository.findById(id).orElseThrow();
public ApiResponse<Void> withdrawReview(@PathVariable Long id,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
ReviewTask task = reviewTaskRepository.findById(id)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id));
reviewService.withdrawReview(task.getSkillVersionId(), userId);
recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}");
return ok("response.success.updated", null);
}
@GetMapping
public ApiResponse<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();
return ok(
"response.success.read",
PageResponse.from(new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size()))
);
}
@GetMapping("/pending")
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(
@RequestParam 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) {
Namespace namespace = namespaceRepository.findById(namespaceId).orElseThrow();
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
if (!permissionChecker.canManageNamespaceReviews(
namespaceId,
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(@RequestParam 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) {
Namespace namespace = namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
ReviewTask probe = new ReviewTask(0L, namespaceId, userId);
if (!reviewService.canReviewNamespace(
probe,
userId,
namespace.getType(),
userNsRoles != null ? userNsRoles : Map.of(),
platformRoles)) {
rbacService.getUserRoleCodes(userId))) {
throw new DomainForbiddenException("review.no_permission");
}
@ -129,53 +199,54 @@ public class ReviewController extends BaseApiController {
}
@GetMapping("/my-submissions")
public ApiResponse<PageResponse<ReviewTaskResponse>> listMySubmissions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
public ApiResponse<PageResponse<ReviewTaskResponse>> listMySubmissions(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
Page<ReviewTask> tasks = reviewTaskRepository.findBySubmittedByAndStatus(
userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse)));
}
@GetMapping("/{id}")
public ApiResponse<ReviewTaskResponse> getReviewDetail(
@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
ReviewTask task = reviewTaskRepository.findById(id).orElseThrow();
Namespace namespace = namespaceRepository.findById(task.getNamespaceId()).orElseThrow();
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
if (!permissionChecker.canReadReview(
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
ReviewTask task = reviewTaskRepository.findById(id)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id));
Namespace namespace = namespaceRepository.findById(task.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId()));
if (!reviewService.canViewReview(
task,
userId,
namespace.getType(),
userNsRoles != null ? userNsRoles : Map.of(),
platformRoles)) {
rbacService.getUserRoleCodes(userId))) {
throw new DomainForbiddenException("review.no_permission");
}
return ok("response.success.read", toResponse(task));
}
private ReviewTaskResponse toResponse(ReviewTask task) {
SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow();
Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow();
Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow();
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId()));
String submittedByName = userAccountRepository.findById(task.getSubmittedBy())
.map(UserAccount::getDisplayName).orElse(null);
.map(UserAccount::getDisplayName)
.orElse(null);
String reviewedByName = task.getReviewedBy() != null
? userAccountRepository.findById(task.getReviewedBy())
.map(UserAccount::getDisplayName).orElse(null)
? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null)
: null;
return new ReviewTaskResponse(
task.getId(),
task.getSkillVersionId(),
ns.getSlug(),
namespace.getSlug(),
skill.getSlug(),
sv.getVersion(),
skillVersion.getVersion(),
task.getStatus().name(),
task.getSubmittedBy(),
submittedByName,
@ -186,4 +257,40 @@ 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

@ -20,6 +20,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -63,6 +64,9 @@ public class SkillController extends BaseApiController {
detail.status(),
detail.downloadCount(),
detail.starCount(),
detail.ratingAvg(),
detail.ratingCount(),
detail.hidden(),
detail.latestVersion(),
namespace
);
@ -75,10 +79,16 @@ public class SkillController extends BaseApiController {
@PathVariable String namespace,
@PathVariable String slug,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
@RequestParam(defaultValue = "20") int size,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
Page<SkillVersion> versions = skillQueryService.listVersions(
namespace, slug, PageRequest.of(page, size));
namespace,
slug,
userId,
userNsRoles != null ? userNsRoles : Map.of(),
PageRequest.of(page, size));
PageResponse<SkillVersionResponse> response = PageResponse.from(versions.map(v -> new SkillVersionResponse(
v.getId(),
@ -272,11 +282,7 @@ public class SkillController extends BaseApiController {
SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest(
namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"")
.contentType(MediaType.parseMediaType(result.contentType()))
.contentLength(result.contentLength())
.body(new InputStreamResource(result.content()));
return buildDownloadResponse(result);
}
@GetMapping("/{namespace}/{slug}/versions/{version}/download")
@ -291,11 +297,7 @@ public class SkillController extends BaseApiController {
SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion(
namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"")
.contentType(MediaType.parseMediaType(result.contentType()))
.contentLength(result.contentLength())
.body(new InputStreamResource(result.content()));
return buildDownloadResponse(result);
}
@GetMapping("/{namespace}/{slug}/tags/{tagName}/download")
@ -310,6 +312,16 @@ public class SkillController extends BaseApiController {
SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag(
namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of());
return buildDownloadResponse(result);
}
private ResponseEntity<InputStreamResource> buildDownloadResponse(SkillDownloadService.DownloadResult result) {
if (result.presignedUrl() != null) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, result.presignedUrl())
.build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"")
.contentType(MediaType.parseMediaType(result.contentType()))

View file

@ -9,6 +9,7 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PublishResponse;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@ -22,13 +23,16 @@ public class SkillPublishController extends BaseApiController {
private final SkillPublishService skillPublishService;
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
private final SkillHubMetrics skillHubMetrics;
public SkillPublishController(SkillPublishService skillPublishService,
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
ApiResponseFactory responseFactory) {
ApiResponseFactory responseFactory,
SkillHubMetrics skillHubMetrics) {
super(responseFactory);
this.skillPublishService = skillPublishService;
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
this.skillHubMetrics = skillHubMetrics;
}
@PostMapping("/{namespace}/publish")
@ -64,6 +68,7 @@ public class SkillPublishController extends BaseApiController {
publishResult.version().getFileCount(),
publishResult.version().getTotalSize()
);
skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name());
return ok("response.success.published", response);
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.SkillTag;
import com.iflytek.skillhub.domain.skill.service.SkillTagService;
import com.iflytek.skillhub.dto.ApiResponse;
@ -12,6 +13,7 @@ import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@ -29,9 +31,16 @@ public class SkillTagController extends BaseApiController {
@GetMapping
public ApiResponse<List<TagResponse>> listTags(
@PathVariable String namespace,
@PathVariable String slug) {
@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
List<SkillTag> tags = skillTagService.listTags(namespace, slug);
List<SkillTag> tags = skillTagService.listTags(
namespace,
slug,
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
List<TagResponse> response = tags.stream()
.map(TagResponse::from)

View file

@ -0,0 +1,127 @@
package com.iflytek.skillhub.controller.support;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@Component
public class ZipPackageExtractor {
private static final int BUFFER_SIZE = 8192;
private final SkillPublishProperties properties;
public ZipPackageExtractor(SkillPublishProperties properties) {
this.properties = properties;
}
public List<PackageEntry> extract(MultipartFile file) throws IOException {
List<PackageEntry> entries = new ArrayList<>();
Set<String> seenPaths = new HashSet<>();
long totalSize = 0L;
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (zipEntry.isDirectory()) {
zis.closeEntry();
continue;
}
if (entries.size() >= properties.getMaxFileCount()) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Too many files: max " + properties.getMaxFileCount());
}
String normalizedPath = normalizeEntryPath(zipEntry.getName());
if (!seenPaths.add(normalizedPath)) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Duplicate package path: " + normalizedPath);
}
byte[] content = readEntry(zis, normalizedPath);
totalSize += content.length;
if (totalSize > properties.getMaxPackageSize()) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Package too large: max " + properties.getMaxPackageSize() + " bytes");
}
entries.add(new PackageEntry(
normalizedPath,
content,
content.length,
determineContentType(normalizedPath)
));
zis.closeEntry();
}
}
return entries;
}
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[BUFFER_SIZE];
int read;
long fileSize = 0L;
while ((read = zis.read(buffer)) != -1) {
fileSize += read;
if (fileSize > properties.getMaxSingleFileSize()) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)");
}
outputStream.write(buffer, 0, read);
}
return outputStream.toByteArray();
}
private String normalizeEntryPath(String path) {
if (path == null || path.isBlank()) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank");
}
if (path.contains("\\")) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Package entry must use '/' separators: " + path);
}
try {
Path normalized = Path.of(path).normalize();
String normalizedPath = normalized.toString().replace('\\', '/');
if (normalized.isAbsolute()
|| normalizedPath.isBlank()
|| normalizedPath.startsWith("../")
|| normalizedPath.equals("..")
|| path.startsWith("/")
|| path.contains("//")) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Unsafe package path: " + path);
}
return normalizedPath;
} catch (InvalidPathException ex) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Invalid package path: " + path);
}
}
private String determineContentType(String filename) {
if (filename.endsWith(".py")) return "text/x-python";
if (filename.endsWith(".json")) return "application/json";
if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml";
if (filename.endsWith(".txt")) return "text/plain";
if (filename.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record AdminSkillActionRequest(String reason) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record AdminSkillMutationResponse(
Long skillId,
Long versionId,
String action,
String status
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record ChangePasswordRequest(
@NotBlank(message = "当前密码不能为空")
String currentPassword,
@NotBlank(message = "新密码不能为空")
String newPassword
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record LocalLoginRequest(
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(message = "密码不能为空")
String password
) {}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LocalRegisterRequest(
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(message = "密码不能为空")
String password,
@Email(message = "邮箱格式不正确")
String email
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record MergeInitiateRequest(
@NotBlank(message = "待合并账号标识不能为空")
String secondaryIdentifier
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record MergeInitiateResponse(
Long mergeRequestId,
String secondaryUserId,
String verificationToken,
String expiresAt
) {}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record MergeVerifyRequest(
@NotNull(message = "合并请求 ID 不能为空")
Long mergeRequestId,
@NotBlank(message = "验证 token 不能为空")
String verificationToken
) {}

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

@ -1,5 +1,6 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
@ -32,6 +33,13 @@ public class GlobalExceptionHandler {
apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(AuthFlowException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex) {
HttpStatus status = ex.getStatus();
return ResponseEntity.status(status).body(
apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs()));
}
@ExceptionHandler(DomainBadRequestException.class)
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
return ResponseEntity.badRequest().body(

View file

@ -0,0 +1,34 @@
package com.iflytek.skillhub.metrics;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
@Component
public class SkillHubMetrics {
private final MeterRegistry meterRegistry;
public SkillHubMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public void incrementUserRegister() {
meterRegistry.counter("skillhub.user.register").increment();
}
public void recordLocalLogin(boolean success) {
meterRegistry.counter(
"skillhub.auth.login",
"method", "local",
"result", success ? "success" : "failure"
).increment();
}
public void incrementSkillPublish(String namespace, String status) {
meterRegistry.counter(
"skillhub.skill.publish",
"namespace", namespace,
"status", status
).increment();
}
}

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(),
user.getStatus().name(),
List.copyOf(roles),
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

@ -1,6 +1,13 @@
server:
port: 8080
shutdown: graceful
servlet:
session:
cookie:
http-only: true
secure: ${SESSION_COOKIE_SECURE:false}
same-site: lax
max-age: 28800
spring:
messages:
@ -53,13 +60,15 @@ skillhub:
access-policy:
mode: OPEN
storage:
type: local
provider: local
local:
base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage}
search:
engine: postgres
rebuild-on-startup: false
publish:
max-file-count: 100
max-single-file-size: 1048576 # 1MB
max-package-size: 104857600 # 100MB
allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh
@ -67,10 +76,16 @@ management:
endpoints:
web:
exposure:
include: health,info
include: health,info,prometheus,metrics
endpoint:
health:
show-details: when-authorized
metrics:
tags:
application: skillhub
export:
prometheus:
enabled: true
---
# Docker profile

View file

@ -0,0 +1,32 @@
CREATE TABLE local_credential (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_local_credential_username ON local_credential (username);
CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential (user_id);
CREATE TABLE account_merge_request (
id BIGSERIAL PRIMARY KEY,
primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
verification_token VARCHAR(255),
token_expires_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_merge_primary_status ON account_merge_request (primary_user_id, status);
CREATE UNIQUE INDEX idx_merge_secondary_pending
ON account_merge_request (secondary_user_id)
WHERE status = 'PENDING';
CREATE INDEX idx_merge_token_pending
ON account_merge_request (verification_token)
WHERE status = 'PENDING';

View file

@ -0,0 +1,11 @@
ALTER TABLE skill ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE skill ADD COLUMN hidden_at TIMESTAMP;
ALTER TABLE skill ADD COLUMN hidden_by VARCHAR(128) REFERENCES user_account(id);
ALTER TABLE skill_version ADD COLUMN yanked_at TIMESTAMP;
ALTER TABLE skill_version ADD COLUMN yanked_by VARCHAR(128) REFERENCES user_account(id);
ALTER TABLE skill_version ADD COLUMN yank_reason TEXT;
CREATE INDEX idx_skill_hidden ON skill(hidden) WHERE hidden = TRUE;
CREATE INDEX idx_audit_log_actor_time ON audit_log(actor_user_id, created_at DESC);
CREATE INDEX idx_audit_log_action_time ON audit_log(action, created_at DESC);

View file

@ -0,0 +1,98 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.DefaultApplicationArguments;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LocalDevDataInitializerTest {
@Mock private UserAccountRepository userAccountRepository;
@Mock private NamespaceRepository namespaceRepository;
@Mock private NamespaceMemberRepository namespaceMemberRepository;
@Mock private RoleRepository roleRepository;
@Mock private UserRoleBindingRepository userRoleBindingRepository;
private LocalDevDataInitializer initializer;
@BeforeEach
void setUp() {
initializer = new LocalDevDataInitializer(
userAccountRepository,
namespaceRepository,
namespaceMemberRepository,
roleRepository,
userRoleBindingRepository
);
}
@Test
void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception {
Namespace global = new Namespace("global", "Global", "system");
setField(global, "id", 1L);
Role superAdminRole = new Role();
setField(superAdminRole, "id", 1L);
setField(superAdminRole, "code", "SUPER_ADMIN");
when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty());
when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty());
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty());
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole));
when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of());
initializer.run(new DefaultApplicationArguments(new String[0]));
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userAccountRepository, times(2)).save(userCaptor.capture());
List<UserAccount> savedUsers = userCaptor.getAllValues();
assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId())));
assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId())));
ArgumentCaptor<NamespaceMember> memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class);
verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture());
assertEquals(
List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID),
memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList()
);
assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER));
ArgumentCaptor<UserRoleBinding> roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class);
verify(userRoleBindingRepository).save(roleBindingCaptor.capture());
assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId());
assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode());
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View file

@ -3,6 +3,7 @@ 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.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillSearchAppService;
import org.junit.jupiter.api.Test;
@ -43,6 +44,9 @@ class ClawHubCompatControllerTest {
@MockBean
private SkillSearchAppService skillSearchAppService;
@MockBean
private SkillQueryService skillQueryService;
@Test
void search_returns_mapped_results() throws Exception {
when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null))
@ -76,6 +80,9 @@ class ClawHubCompatControllerTest {
@Test
void resolve_returns_correct_downloadUrl() throws Exception {
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
mockMvc.perform(get("/api/compat/v1/resolve/my-skill"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
@ -85,6 +92,9 @@ class ClawHubCompatControllerTest {
@Test
void resolve_with_namespace_returns_correct_downloadUrl() throws Exception {
when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download"));
mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill"))
@ -94,6 +104,9 @@ class ClawHubCompatControllerTest {
@Test
void resolve_with_version_returns_specified_version() throws Exception {
when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "global", "my-skill", "1.0.0", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
mockMvc.perform(get("/api/compat/v1/resolve/my-skill")
.param("version", "1.0.0"))
.andExpect(status().isOk())

View file

@ -0,0 +1,100 @@
package com.iflytek.skillhub.controller;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.merge.AccountMergeService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AccountMergeControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private AccountMergeService accountMergeService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void initiate_returnsVerificationToken() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of());
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of());
given(accountMergeService.initiate("usr_primary", "secondary"))
.willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", LocalDateTime.parse("2026-03-12T22:30:00")));
mockMvc.perform(post("/api/v1/account/merge/initiate")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"secondaryIdentifier":"secondary"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.mergeRequestId").value(1))
.andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary"))
.andExpect(jsonPath("$.data.verificationToken").value("merge-token"));
}
@Test
void verify_returnsSuccessMessage() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of());
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")));
mockMvc.perform(post("/api/v1/account/merge/verify")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"mergeRequestId":1,"verificationToken":"merge-token"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.message").value("Account merge verified"));
verify(accountMergeService).verify("usr_primary", 1L, "merge-token");
}
@Test
void confirm_returnsSuccessMessage() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of());
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")));
mockMvc.perform(post("/api/v1/account/merge/confirm")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"mergeRequestId":1}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.message").value("Account merge completed"));
verify(accountMergeService).confirm("usr_primary", 1L);
}
}

View file

@ -18,6 +18,7 @@ import java.util.Set;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -59,6 +60,9 @@ class AuthControllerTest {
mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Frame-Options", "DENY"))
.andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").isNotEmpty())
.andExpect(jsonPath("$.data.userId").value("user-42"))

View file

@ -0,0 +1,151 @@
package com.iflytek.skillhub.controller;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class LocalAuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private LocalAuthService localAuthService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private SkillHubMetrics skillHubMetrics;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
"alice",
"alice@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
given(localAuthService.login("alice", "Abcd123!")).willReturn(principal);
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"Abcd123!"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_1"))
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
verify(skillHubMetrics).recordLocalLogin(true);
verify(skillHubMetrics, never()).recordLocalLogin(false);
}
@Test
void register_returnsCreatedEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_2",
"bob",
"bob@example.com",
"",
"local",
Set.of()
);
given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal);
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"bob","password":"Abcd123!","email":"bob@example.com"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.displayName").value("bob"));
verify(skillHubMetrics).incrementUserRegister();
}
@Test
void login_failure_recordsFailureMetric() throws Exception {
given(localAuthService.login("alice", "wrong"))
.willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"));
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"wrong"}
"""))
.andExpect(status().isUnauthorized());
verify(skillHubMetrics).recordLocalLogin(false);
verify(skillHubMetrics, never()).recordLocalLogin(true);
}
@Test
void changePassword_requiresAuthentication() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isUnauthorized());
}
@Test
void changePassword_withAuthentication_returnsUpdated() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_3",
"carol",
"carol@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
}

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -84,11 +85,15 @@ class PromotionPortalControllerTest {
@MockBean
private ReviewPermissionChecker permissionChecker;
@MockBean
private AuditLogService auditLogService;
@Test
void submitPromotion_passesNamespaceRolesToService() throws Exception {
PromotionRequest request = createPromotionRequest(1L, "user-1");
stubNamespaceRoles("user-1", List.of(new NamespaceMember(5L, "user-1", NamespaceRole.ADMIN)));
given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN)))
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN), Set.of()))
.willReturn(request);
stubPromotionResponse(request);
@ -121,7 +126,7 @@ class PromotionPortalControllerTest {
stubNamespaceRoles("user-1", List.of());
given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(permissionChecker.canReadPromotion(request, "user-1", Set.of())).willReturn(true);
given(promotionService.canViewPromotion(request, "user-1", Set.of())).willReturn(true);
stubPromotionResponse(request);
mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-1")))
@ -136,7 +141,7 @@ class PromotionPortalControllerTest {
stubNamespaceRoles("user-9", List.of());
given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request));
given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of());
given(permissionChecker.canReadPromotion(request, "user-9", Set.of())).willReturn(false);
given(promotionService.canViewPromotion(request, "user-9", Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-9")))
.andExpect(status().isForbidden())

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -84,11 +85,15 @@ class ReviewPortalControllerTest {
@MockBean
private ReviewPermissionChecker permissionChecker;
@MockBean
private AuditLogService auditLogService;
@Test
void submitReview_passesNamespaceRolesToService() throws Exception {
ReviewTask task = createReviewTask(1L, 20L, "user-1");
stubNamespaceRoles("user-1", List.of(new NamespaceMember(20L, "user-1", NamespaceRole.MEMBER)));
given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER))).willReturn(task);
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER), Set.of())).willReturn(task);
stubReviewResponse(task);
mockMvc.perform(post("/api/v1/reviews")
@ -130,7 +135,7 @@ class ReviewPortalControllerTest {
given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(permissionChecker.canReadReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true);
given(reviewService.canViewReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true);
stubReviewResponse(task);
mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-1")))
@ -147,7 +152,7 @@ class ReviewPortalControllerTest {
given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of());
given(permissionChecker.canReadReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false);
given(reviewService.canViewReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-9")))
.andExpect(status().isForbidden())

View file

@ -12,8 +12,10 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -35,7 +37,7 @@ class SkillTagControllerTest {
@Test
void list_tags_is_public() throws Exception {
when(skillTagService.listTags(eq("team"), eq("demo")))
when(skillTagService.listTags(eq("team"), eq("demo"), isNull(), eq(Map.of())))
.thenReturn(List.of(new SkillTag(1L, "latest", 2L, "user-1")));
mockMvc.perform(get("/api/v1/skills/team/demo/tags"))

View file

@ -0,0 +1,89 @@
package com.iflytek.skillhub.controller.admin;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AdminSkillControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SkillGovernanceService skillGovernanceService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void hideSkill_returnsUpdatedResponse() throws Exception {
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy")))
.willReturn(skill);
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN"));
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")));
mockMvc.perform(post("/api/v1/admin/skills/10/hide")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"policy\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.skillId").value(10))
.andExpect(jsonPath("$.data.action").value("HIDE"));
}
@Test
void yankVersion_returnsUpdatedResponse() throws Exception {
SkillVersion version = new SkillVersion(10L, "1.0.0", "owner");
version.setStatus(SkillVersionStatus.YANKED);
given(skillGovernanceService.yankVersion(org.mockito.ArgumentMatchers.eq(33L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("broken")))
.willReturn(version);
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN"));
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")));
mockMvc.perform(post("/api/v1/admin/skills/versions/33/yank")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"broken\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.versionId").value(33))
.andExpect(jsonPath("$.data.action").value("YANK"))
.andExpect(jsonPath("$.data.status").value("YANKED"));
}
}

View file

@ -0,0 +1,75 @@
package com.iflytek.skillhub.controller.portal;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import java.io.ByteArrayInputStream;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class SkillControllerDownloadTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SkillQueryService skillQueryService;
@MockBean
private SkillDownloadService skillDownloadService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception {
given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of()))
.willReturn(new SkillDownloadService.DownloadResult(
null,
"demo-skill-1.0.0.zip",
128L,
"application/zip",
"https://download.example/presigned"
));
mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "https://download.example/presigned"));
}
@Test
void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception {
given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of()))
.willReturn(new SkillDownloadService.DownloadResult(
new ByteArrayInputStream("zip".getBytes()),
"demo-skill-1.0.0.zip",
3L,
"application/zip",
null
));
mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\""));
}
}

View file

@ -0,0 +1,123 @@
package com.iflytek.skillhub.controller.portal;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class SkillPublishControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SkillPublishService skillPublishService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private SkillHubMetrics skillHubMetrics;
@Test
void publish_recordsMetricsAfterSuccess() throws Exception {
SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1");
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
version.setFileCount(1);
version.setTotalSize(128L);
ReflectionTestUtils.setField(version, "id", 34L);
given(skillPublishService.publishFromEntries(eq("global"), anyList(), eq("usr_1"), eq(SkillVisibility.PUBLIC)))
.willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
"publisher",
"publisher@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
buildZipBytes()
);
mockMvc.perform(multipart("/api/v1/skills/global/publish")
.file(file)
.param("visibility", "PUBLIC")
.requestAttr("userId", "usr_1")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.skillId").value(12))
.andExpect(jsonPath("$.data.slug").value("demo-skill"));
verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW");
}
private byte[] buildZipBytes() throws Exception {
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
zip.putNextEntry(new ZipEntry("SKILL.md"));
zip.write("""
---
name: Demo Skill
version: 1.0.0
---
""".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
return output.toByteArray();
}
}
}

View file

@ -0,0 +1,57 @@
package com.iflytek.skillhub.metrics;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.core.env.Environment;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class PrometheusEndpointTest {
@Autowired
private SkillHubMetrics skillHubMetrics;
@Autowired
private MeterRegistry meterRegistry;
@Autowired
private Environment environment;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void prometheusEndpoint_exposesCustomMetrics() {
skillHubMetrics.incrementUserRegister();
skillHubMetrics.recordLocalLogin(true);
skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW");
assertThat(environment.getProperty("management.endpoints.web.exposure.include"))
.contains("prometheus");
assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d);
assertThat(meterRegistry.get("skillhub.auth.login")
.tag("method", "local")
.tag("result", "success")
.counter()
.count()).isEqualTo(1.0d);
assertThat(meterRegistry.get("skillhub.skill.publish")
.tag("namespace", "global")
.tag("status", "PENDING_REVIEW")
.counter()
.count()).isEqualTo(1.0d);
}
}

View file

@ -7,7 +7,7 @@
<parent>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.1.0-beta.2</version>
</parent>
<artifactId>skillhub-auth</artifactId>
<dependencies>

View file

@ -11,6 +11,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpMethod;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -21,6 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@ -70,14 +73,17 @@ public class SecurityConfig {
"/api/v1/health",
"/api/v1/auth/providers",
"/api/v1/auth/me",
"/api/v1/auth/local/**",
"/api/v1/cli/auth/device/**",
"/api/v1/cli/check",
"/actuator/health",
"/actuator/prometheus",
"/v3/api-docs/**",
"/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/*/star", "/api/v1/skills/*/rating").authenticated()
.requestMatchers(
@ -105,6 +111,14 @@ public class SecurityConfig {
.successHandler(successHandler)
.failureHandler(failureHandler)
)
.headers(headers -> headers
.contentTypeOptions(contentTypeOptions -> {})
.frameOptions(frameOptions -> frameOptions.deny())
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
.referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
@ -131,4 +145,9 @@ public class SecurityConfig {
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}

View file

@ -62,7 +62,11 @@ public class ApiToken {
void prePersist() { this.createdAt = LocalDateTime.now(); }
public Long getId() { return id; }
public String getSubjectType() { return subjectType; }
public String getSubjectId() { return subjectId; }
public void setSubjectId(String subjectId) { this.subjectId = subjectId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getName() { return name; }
public String getTokenPrefix() { return tokenPrefix; }
public String getTokenHash() { return tokenHash; }

View file

@ -33,5 +33,6 @@ public class UserRoleBinding {
public Long getId() { return id; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public Role getRole() { return role; }
}

View file

@ -0,0 +1,29 @@
package com.iflytek.skillhub.auth.exception;
import org.springframework.http.HttpStatus;
public class AuthFlowException extends RuntimeException {
private final HttpStatus status;
private final String messageCode;
private final Object[] messageArgs;
public AuthFlowException(HttpStatus status, String messageCode, Object... messageArgs) {
super(messageCode);
this.status = status;
this.messageCode = messageCode;
this.messageArgs = messageArgs;
}
public HttpStatus getStatus() {
return status;
}
public String getMessageCode() {
return messageCode;
}
public Object[] getMessageArgs() {
return messageArgs;
}
}

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

@ -0,0 +1,193 @@
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;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class LocalAuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$");
private static final int MAX_FAILED_ATTEMPTS = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
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;
}
@Transactional
public PlatformPrincipal register(String username, String password, String email) {
String normalizedUsername = normalizeUsername(username);
validateUsername(normalizedUsername);
if (credentialRepository.existsByUsernameIgnoreCase(normalizedUsername)) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.username.exists");
}
String normalizedEmail = normalizeEmail(email);
if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists");
}
var passwordErrors = passwordPolicyValidator.validate(password);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
UserAccount user = new UserAccount(
"usr_" + UUID.randomUUID(),
normalizedUsername,
normalizedEmail,
null
);
user.setStatus(UserStatus.ACTIVE);
userAccountRepository.save(user);
credentialRepository.save(new LocalCredential(
user.getId(),
normalizedUsername,
passwordEncoder.encode(password)
));
globalNamespaceMembershipService.ensureMember(user.getId());
return buildPrincipal(user);
}
@Transactional
public PlatformPrincipal login(String username, String password) {
String normalizedUsername = normalizeUsername(username);
LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
.orElseThrow(() -> invalidCredentials());
UserAccount user = userAccountRepository.findById(credential.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for local credential"));
ensureUserCanLogin(user);
ensureNotLocked(credential);
if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
throw invalidCredentials();
}
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
return buildPrincipal(user);
}
@Transactional
public void changePassword(String userId, String currentPassword, String newPassword) {
LocalCredential credential = credentialRepository.findByUserId(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.notEnabled"));
if (!passwordEncoder.matches(currentPassword, credential.getPasswordHash())) {
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials");
}
var passwordErrors = passwordPolicyValidator.validate(newPassword);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
credential.setPasswordHash(passwordEncoder.encode(newPassword));
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
}
private PlatformPrincipal buildPrincipal(UserAccount user) {
Set<String> roles = userRoleBindingRepository.findByUserId(user.getId()).stream()
.map(binding -> binding.getRole().getCode())
.collect(Collectors.toSet());
return new PlatformPrincipal(
user.getId(),
user.getDisplayName(),
user.getEmail(),
user.getAvatarUrl(),
"local",
roles
);
}
private void ensureUserCanLogin(UserAccount user) {
if (user.getStatus() == UserStatus.DISABLED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled");
}
if (user.getStatus() == UserStatus.PENDING) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending");
}
if (user.getStatus() == UserStatus.MERGED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged");
}
}
private void ensureNotLocked(LocalCredential credential) {
if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(LocalDateTime.now())) {
long minutes = Math.max(1, Duration.between(LocalDateTime.now(), credential.getLockedUntil()).toMinutes());
throw new AuthFlowException(HttpStatus.LOCKED, "error.auth.local.locked", minutes);
}
}
private void handleFailedLogin(LocalCredential credential) {
int failedAttempts = credential.getFailedAttempts() + 1;
credential.setFailedAttempts(failedAttempts);
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
credential.setLockedUntil(LocalDateTime.now().plus(LOCK_DURATION));
}
credentialRepository.save(credential);
}
private AuthFlowException invalidCredentials() {
return new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials");
}
private String normalizeUsername(String username) {
return username == null ? "" : username.trim().toLowerCase(Locale.ROOT);
}
private String normalizeEmail(String email) {
if (email == null || email.isBlank()) {
return null;
}
return email.trim().toLowerCase(Locale.ROOT);
}
private void validateUsername(String username) {
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid");
}
}
}

View file

@ -0,0 +1,101 @@
package com.iflytek.skillhub.auth.local;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "local_credential")
public class LocalCredential {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, length = 128, unique = true)
private String userId;
@Column(nullable = false, length = 64, unique = true)
private String username;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "failed_attempts", nullable = false)
private int failedAttempts;
@Column(name = "locked_until")
private LocalDateTime lockedUntil;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected LocalCredential() {}
public LocalCredential(String userId, String username, String passwordHash) {
this.userId = userId;
this.username = username;
this.passwordHash = passwordHash;
this.failedAttempts = 0;
}
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public int getFailedAttempts() {
return failedAttempts;
}
public void setFailedAttempts(int failedAttempts) {
this.failedAttempts = failedAttempts;
}
public LocalDateTime getLockedUntil() {
return lockedUntil;
}
public void setLockedUntil(LocalDateTime lockedUntil) {
this.lockedUntil = lockedUntil;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.auth.local;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LocalCredentialRepository extends JpaRepository<LocalCredential, Long> {
Optional<LocalCredential> findByUsernameIgnoreCase(String username);
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
}

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.auth.local;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class PasswordPolicyValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 128;
private static final int MIN_CHAR_TYPES = 3;
public List<String> validate(String password) {
List<String> errors = new ArrayList<>();
if (password == null || password.length() < MIN_LENGTH) {
errors.add("error.auth.local.password.tooShort");
return errors;
}
if (password.length() > MAX_LENGTH) {
errors.add("error.auth.local.password.tooLong");
return errors;
}
int typeCount = 0;
if (password.chars().anyMatch(Character::isLowerCase)) {
typeCount++;
}
if (password.chars().anyMatch(Character::isUpperCase)) {
typeCount++;
}
if (password.chars().anyMatch(Character::isDigit)) {
typeCount++;
}
if (password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch))) {
typeCount++;
}
if (typeCount < MIN_CHAR_TYPES) {
errors.add("error.auth.local.password.tooWeak");
}
return errors;
}
}

View file

@ -0,0 +1,76 @@
package com.iflytek.skillhub.auth.merge;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "account_merge_request")
public class AccountMergeRequest {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_VERIFIED = "VERIFIED";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_CANCELLED = "CANCELLED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "primary_user_id", nullable = false, length = 128)
private String primaryUserId;
@Column(name = "secondary_user_id", nullable = false, length = 128)
private String secondaryUserId;
@Column(nullable = false, length = 32)
private String status = STATUS_PENDING;
@Column(name = "verification_token", length = 255)
private String verificationToken;
@Column(name = "token_expires_at")
private LocalDateTime tokenExpiresAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
protected AccountMergeRequest() {}
public AccountMergeRequest(String primaryUserId,
String secondaryUserId,
String verificationToken,
LocalDateTime tokenExpiresAt) {
this.primaryUserId = primaryUserId;
this.secondaryUserId = secondaryUserId;
this.verificationToken = verificationToken;
this.tokenExpiresAt = tokenExpiresAt;
this.status = STATUS_PENDING;
}
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public String getPrimaryUserId() { return primaryUserId; }
public String getSecondaryUserId() { return secondaryUserId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getVerificationToken() { return verificationToken; }
public void setVerificationToken(String verificationToken) { this.verificationToken = verificationToken; }
public LocalDateTime getTokenExpiresAt() { return tokenExpiresAt; }
public void setTokenExpiresAt(LocalDateTime tokenExpiresAt) { this.tokenExpiresAt = tokenExpiresAt; }
public LocalDateTime getCompletedAt() { return completedAt; }
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.auth.merge;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AccountMergeRequestRepository extends JpaRepository<AccountMergeRequest, Long> {
Optional<AccountMergeRequest> findByIdAndPrimaryUserId(Long id, String primaryUserId);
boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status);
}

View file

@ -0,0 +1,275 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalCredential;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountMergeService {
private static final Comparator<NamespaceRole> NAMESPACE_ROLE_ORDER = Comparator.comparingInt(role -> switch (role) {
case MEMBER -> 0;
case ADMIN -> 1;
case OWNER -> 2;
});
private final AccountMergeRequestRepository mergeRequestRepository;
private final UserAccountRepository userAccountRepository;
private final LocalCredentialRepository localCredentialRepository;
private final IdentityBindingRepository identityBindingRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final ApiTokenRepository apiTokenRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final PasswordEncoder passwordEncoder;
private final SecureRandom secureRandom = new SecureRandom();
public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository,
UserAccountRepository userAccountRepository,
LocalCredentialRepository localCredentialRepository,
IdentityBindingRepository identityBindingRepository,
UserRoleBindingRepository userRoleBindingRepository,
ApiTokenRepository apiTokenRepository,
NamespaceMemberRepository namespaceMemberRepository,
PasswordEncoder passwordEncoder) {
this.mergeRequestRepository = mergeRequestRepository;
this.userAccountRepository = userAccountRepository;
this.localCredentialRepository = localCredentialRepository;
this.identityBindingRepository = identityBindingRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.apiTokenRepository = apiTokenRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.passwordEncoder = passwordEncoder;
}
public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, LocalDateTime expiresAt) {}
@Transactional
public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) {
UserAccount primaryUser = loadActiveUser(primaryUserId);
UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier);
validateMergePair(primaryUser, secondaryUser);
if (mergeRequestRepository.existsBySecondaryUserIdAndStatus(
secondaryUser.getId(),
AccountMergeRequest.STATUS_PENDING
)) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists");
}
Optional<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
Optional<LocalCredential> secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId());
if (primaryCredential.isPresent() && secondaryCredential.isPresent()) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict");
}
String rawToken = generateVerificationToken();
AccountMergeRequest request = new AccountMergeRequest(
primaryUserId,
secondaryUser.getId(),
passwordEncoder.encode(rawToken),
LocalDateTime.now().plusMinutes(30)
);
request = mergeRequestRepository.save(request);
return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt());
}
@Transactional
public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) {
AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"));
if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending");
}
if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(LocalDateTime.now())) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired");
}
if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) {
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken");
}
loadActiveUser(primaryUserId);
UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId())
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
validateMergePair(loadActiveUser(primaryUserId), secondaryUser);
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
mergeRequestRepository.save(request);
}
@Transactional
public void confirm(String primaryUserId, Long mergeRequestId) {
AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"));
if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified");
}
UserAccount primaryUser = loadActiveUser(primaryUserId);
UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId())
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
validateMergePair(primaryUser, secondaryUser);
migrateIdentityBindings(primaryUser.getId(), secondaryUser.getId());
migrateApiTokens(primaryUser.getId(), secondaryUser.getId());
migrateUserRoles(primaryUser.getId(), secondaryUser.getId());
migrateNamespaceMemberships(primaryUser.getId(), secondaryUser.getId());
migrateLocalCredential(primaryUser.getId(), secondaryUser.getId());
if ((primaryUser.getEmail() == null || primaryUser.getEmail().isBlank())
&& secondaryUser.getEmail() != null && !secondaryUser.getEmail().isBlank()) {
primaryUser.setEmail(secondaryUser.getEmail());
}
userAccountRepository.save(primaryUser);
secondaryUser.setStatus(UserStatus.MERGED);
secondaryUser.setMergedToUserId(primaryUser.getId());
userAccountRepository.save(secondaryUser);
request.setStatus(AccountMergeRequest.STATUS_COMPLETED);
request.setCompletedAt(LocalDateTime.now());
request.setVerificationToken(null);
mergeRequestRepository.save(request);
}
private UserAccount resolveSecondaryUser(String identifier) {
String normalized = identifier == null ? "" : identifier.trim();
if (normalized.isBlank()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierRequired");
}
if (normalized.contains(":")) {
String[] parts = normalized.split(":", 2);
if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierInvalid");
}
IdentityBinding binding = identityBindingRepository.findByProviderCodeAndSubject(parts[0], parts[1])
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
return userAccountRepository.findById(binding.getUserId())
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
}
LocalCredential credential = localCredentialRepository.findByUsernameIgnoreCase(normalized.toLowerCase(Locale.ROOT))
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
return userAccountRepository.findById(credential.getUserId())
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
}
private UserAccount loadActiveUser(String userId) {
UserAccount user = userAccountRepository.findById(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.primaryNotFound"));
if (user.getStatus() != UserStatus.ACTIVE) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.primaryNotActive");
}
return user;
}
private void validateMergePair(UserAccount primaryUser, UserAccount secondaryUser) {
if (primaryUser.getId().equals(secondaryUser.getId())) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.sameAccount");
}
if (secondaryUser.getStatus() != UserStatus.ACTIVE) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.secondaryNotActive");
}
}
private void migrateIdentityBindings(String primaryUserId, String secondaryUserId) {
List<IdentityBinding> bindings = identityBindingRepository.findByUserId(secondaryUserId);
for (IdentityBinding binding : bindings) {
binding.setUserId(primaryUserId);
}
identityBindingRepository.saveAll(bindings);
}
private void migrateApiTokens(String primaryUserId, String secondaryUserId) {
List<ApiToken> tokens = apiTokenRepository.findByUserId(secondaryUserId);
for (ApiToken token : tokens) {
token.setUserId(primaryUserId);
if ("USER".equals(token.getSubjectType())) {
token.setSubjectId(primaryUserId);
}
}
apiTokenRepository.saveAll(tokens);
}
private void migrateUserRoles(String primaryUserId, String secondaryUserId) {
Set<String> primaryRoleCodes = new HashSet<>();
for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) {
primaryRoleCodes.add(binding.getRole().getCode());
}
List<UserRoleBinding> secondaryBindings = userRoleBindingRepository.findByUserId(secondaryUserId);
for (UserRoleBinding binding : secondaryBindings) {
Role role = binding.getRole();
if (!primaryRoleCodes.contains(role.getCode())) {
userRoleBindingRepository.save(new UserRoleBinding(primaryUserId, role));
primaryRoleCodes.add(role.getCode());
}
}
userRoleBindingRepository.deleteAll(secondaryBindings);
}
private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) {
List<NamespaceMember> secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId);
for (NamespaceMember secondaryMembership : secondaryMemberships) {
Optional<NamespaceMember> existingPrimaryMembership = namespaceMemberRepository
.findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId);
if (existingPrimaryMembership.isPresent()) {
NamespaceMember primaryMembership = existingPrimaryMembership.get();
if (NAMESPACE_ROLE_ORDER.compare(secondaryMembership.getRole(), primaryMembership.getRole()) > 0) {
primaryMembership.setRole(secondaryMembership.getRole());
namespaceMemberRepository.save(primaryMembership);
}
namespaceMemberRepository.deleteByNamespaceIdAndUserId(
secondaryMembership.getNamespaceId(),
secondaryUserId
);
} else {
secondaryMembership.setUserId(primaryUserId);
namespaceMemberRepository.save(secondaryMembership);
}
}
}
private void migrateLocalCredential(String primaryUserId, String secondaryUserId) {
Optional<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
Optional<LocalCredential> secondaryCredential = localCredentialRepository.findByUserId(secondaryUserId);
if (primaryCredential.isPresent() && secondaryCredential.isPresent()) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict");
}
secondaryCredential.ifPresent(credential -> {
credential.setUserId(primaryUserId);
localCredentialRepository.save(credential);
});
}
private String generateVerificationToken() {
byte[] tokenBytes = new byte[24];
secureRandom.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}
}

View file

@ -9,5 +9,6 @@ import java.util.Optional;
@Repository
public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
Optional<ApiToken> findByTokenHash(String tokenHash);
List<ApiToken> findByUserId(String userId);
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
}

View file

@ -8,4 +8,5 @@ import java.util.Optional;
@Repository
public interface IdentityBindingRepository extends JpaRepository<IdentityBinding, Long> {
Optional<IdentityBinding> findByProviderCodeAndSubject(String providerCode, String subject);
java.util.List<IdentityBinding> findByUserId(String userId);
}

View file

@ -11,5 +11,5 @@ import java.util.List;
public interface UserRoleBindingRepository extends JpaRepository<UserRoleBinding, Long> {
List<UserRoleBinding> findByUserId(String userId);
List<UserRoleBinding> findByUserIdIn(Collection<String> userIds);
void deleteByUserId(String userId);
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

@ -0,0 +1,134 @@
package com.iflytek.skillhub.auth.local;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class LocalAuthServiceTest {
@Mock
private LocalCredentialRepository credentialRepository;
@Mock
private UserAccountRepository userAccountRepository;
@Mock
private UserRoleBindingRepository userRoleBindingRepository;
@Mock
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
@Mock
private PasswordEncoder passwordEncoder;
private LocalAuthService service;
@BeforeEach
void setUp() {
service = new LocalAuthService(
credentialRepository,
userAccountRepository,
userRoleBindingRepository,
globalNamespaceMembershipService,
new PasswordPolicyValidator(),
passwordEncoder
);
}
@Test
void register_createsUserAndCredential() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty());
given(passwordEncoder.encode("Abcd123!")).willReturn("encoded");
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0));
given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of());
var principal = service.register("Alice", "Abcd123!", "alice@example.com");
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userAccountRepository).save(userCaptor.capture());
assertThat(userCaptor.getValue().getDisplayName()).isEqualTo("alice");
assertThat(principal.displayName()).isEqualTo("alice");
assertThat(principal.email()).isEqualTo("alice@example.com");
verify(credentialRepository).save(any(LocalCredential.class));
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
}
@Test
void login_withValidPassword_resetsCounters() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
credential.setFailedAttempts(3);
credential.setLockedUntil(LocalDateTime.now().minusMinutes(1));
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
Role role = mock(Role.class);
given(role.getCode()).willReturn("USER_ADMIN");
UserRoleBinding binding = new UserRoleBinding("usr_1", role);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding));
var principal = service.login("alice", "Abcd123!");
assertThat(credential.getFailedAttempts()).isZero();
assertThat(credential.getLockedUntil()).isNull();
assertThat(principal.platformRoles()).containsExactly("USER_ADMIN");
}
@Test
void login_withInvalidPassword_incrementsCounter() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
assertThatThrownBy(() -> service.login("alice", "bad"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(credential.getFailedAttempts()).isEqualTo(1);
verify(credentialRepository).save(credential);
}
@Test
void login_withDisabledAccount_fails() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.DISABLED);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.accountDisabled");
}
}

View file

@ -0,0 +1,38 @@
package com.iflytek.skillhub.auth.local;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class PasswordPolicyValidatorTest {
private final PasswordPolicyValidator validator = new PasswordPolicyValidator();
@Test
void validPassword_passes() {
assertThat(validator.validate("Abcdef1!")).isEmpty();
}
@Test
void tooShort_fails() {
assertThat(validator.validate("Ab1!xyz")).containsExactly("error.auth.local.password.tooShort");
}
@Test
void tooLong_fails() {
assertThat(validator.validate("A".repeat(129))).containsExactly("error.auth.local.password.tooLong");
}
@Test
void twoCharTypes_fails() {
assertThat(validator.validate("abcdefgh1")).containsExactly("error.auth.local.password.tooWeak");
}
@ParameterizedTest
@ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"})
void threeCharTypes_pass(String password) {
assertThat(validator.validate(password)).isEmpty();
}
}

View file

@ -0,0 +1,178 @@
package com.iflytek.skillhub.auth.merge;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalCredential;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class AccountMergeServiceTest {
@Mock
private AccountMergeRequestRepository mergeRequestRepository;
@Mock
private UserAccountRepository userAccountRepository;
@Mock
private LocalCredentialRepository localCredentialRepository;
@Mock
private IdentityBindingRepository identityBindingRepository;
@Mock
private UserRoleBindingRepository userRoleBindingRepository;
@Mock
private ApiTokenRepository apiTokenRepository;
@Mock
private NamespaceMemberRepository namespaceMemberRepository;
@Mock
private PasswordEncoder passwordEncoder;
private AccountMergeService service;
@BeforeEach
void setUp() {
service = new AccountMergeService(
mergeRequestRepository,
userAccountRepository,
localCredentialRepository,
identityBindingRepository,
userRoleBindingRepository,
apiTokenRepository,
namespaceMemberRepository,
passwordEncoder
);
}
@Test
void initiate_withLocalUsername_createsPendingRequest() {
UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null);
UserAccount secondary = new UserAccount("usr_secondary", "secondary", "secondary@example.com", null);
LocalCredential secondaryCredential = new LocalCredential("usr_secondary", "secondary", "hash");
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential));
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING))
.willReturn(false);
given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty());
given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential));
given(passwordEncoder.encode(any())).willReturn("encoded-token");
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
var result = service.initiate("usr_primary", "secondary");
assertThat(result.secondaryUserId()).isEqualTo("usr_secondary");
assertThat(result.verificationToken()).isNotBlank();
verify(mergeRequestRepository).save(any(AccountMergeRequest.class));
}
@Test
void verify_marksRequestVerifiedWhenTokenMatches() throws Exception {
UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null);
UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null);
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true);
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
service.verify("usr_primary", 7L, "raw-token");
assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED);
verify(mergeRequestRepository).save(request);
}
@Test
void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception {
UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null);
UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null);
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
Role role = mock(Role.class);
given(role.getCode()).willReturn("AUDITOR");
UserRoleBinding secondaryRole = new UserRoleBinding("usr_secondary", role);
IdentityBinding binding = new IdentityBinding("usr_secondary", "github", "gh_123", "secondary");
ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]");
NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN);
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding));
given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token));
given(userRoleBindingRepository.findByUserId("usr_primary")).willReturn(List.of());
given(userRoleBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryRole));
given(namespaceMemberRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryMembership));
given(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_primary")).willReturn(Optional.empty());
given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty());
given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty());
service.confirm("usr_primary", 7L);
assertThat(binding.getUserId()).isEqualTo("usr_primary");
assertThat(token.getUserId()).isEqualTo("usr_primary");
assertThat(token.getSubjectId()).isEqualTo("usr_primary");
assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary");
assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED);
assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary");
assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_COMPLETED);
assertThat(request.getVerificationToken()).isNull();
verify(userRoleBindingRepository).save(any(UserRoleBinding.class));
verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole));
}
@Test
void verify_rejectsInvalidToken() throws Exception {
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false);
assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.merge.invalidToken");
verify(identityBindingRepository, never()).saveAll(any());
}
private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception {
AccountMergeRequest request = new AccountMergeRequest(
primaryUserId,
secondaryUserId,
token,
LocalDateTime.now().plusMinutes(10)
);
Field idField = AccountMergeRequest.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(request, 7L);
return request;
}
}

Some files were not shown because too many files have changed in this diff Show more