mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
merge(main): sync latest origin/main into feature/project-review
This commit is contained in:
commit
447a34a1d1
204 changed files with 19208 additions and 1473 deletions
18
.env.release.example
Normal file
18
.env.release.example
Normal 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
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
29
.github/pull_request_template.md
vendored
Normal 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
81
.github/workflows/publish-images.yml
vendored
Normal 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
43
.github/workflows/validate-openapi.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Validate OpenAPI SDK
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feature/project-init
|
||||
|
||||
jobs:
|
||||
openapi-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: maven
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate generated OpenAPI SDK
|
||||
run: ./scripts/check-openapi-generated.sh
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -54,6 +54,8 @@ coverage/
|
|||
# Temporary files
|
||||
.tmp/
|
||||
tmp/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
33
CODE_OF_CONDUCT.md
Normal file
33
CODE_OF_CONDUCT.md
Normal 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
81
CONTRIBUTING.md
Normal 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
214
LICENSE
|
|
@ -1,21 +1,201 @@
|
|||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2026 iFLYTEK
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
1. Definitions.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets.) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
|
|||
48
Makefile
48
Makefile
|
|
@ -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
154
README.md
|
|
@ -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
76
compose.release.yml
Normal 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:
|
||||
89
deploy/k8s/backend-deployment.yaml
Normal file
89
deploy/k8s/backend-deployment.yaml
Normal 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
19
deploy/k8s/configmap.yaml
Normal 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
|
||||
35
deploy/k8s/frontend-deployment.yaml
Normal file
35
deploy/k8s/frontend-deployment.yaml
Normal 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
26
deploy/k8s/ingress.yaml
Normal 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
|
||||
11
deploy/k8s/secret.yaml.example
Normal file
11
deploy/k8s/secret.yaml.example
Normal 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
27
deploy/k8s/services.yaml
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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/
|
|||
- 缓存/Session:Spring Session + Redis
|
||||
- 数据库迁移:Flyway
|
||||
- 认证:Spring Security OAuth2 Client(一期 GitHub)
|
||||
- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签
|
||||
- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest
|
||||
|
|
|
|||
|
|
@ -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/MinIO),Mock 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 管理 schema,Hibernate 仅校验
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
server:
|
||||
shutdown: graceful
|
||||
|
||||
spring.lifecycle.timeout-per-shutdown-phase: 30s
|
||||
```
|
||||
|
||||
`application-local.yml`(本地开发,对应 Docker Compose):
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/skillhub
|
||||
username: skillhub
|
||||
password: skillhub_dev
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
jpa:
|
||||
show-sql: true
|
||||
|
||||
skillhub:
|
||||
storage:
|
||||
type: s3
|
||||
endpoint: http://localhost:9000
|
||||
access-key: minioadmin
|
||||
secret-key: minioadmin
|
||||
bucket: skillhub
|
||||
region: us-east-1
|
||||
access-policy:
|
||||
mode: OPEN # 本地开发默认开放准入
|
||||
```
|
||||
|
||||
`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 |
|
||||
| 告警 | 基于 Prometheus(5xx 率、延迟 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 自带数据库锁
|
||||
- 启动策略:应用容器启动时自动执行迁移
|
||||
|
|
|
|||
17
monitoring/docker-compose.monitoring.yml
Normal file
17
monitoring/docker-compose.monitoring.yml
Normal 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
10
monitoring/prometheus.yml
Normal 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
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "homepage-redesign",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
46
scripts/check-openapi-generated.sh
Executable file
46
scripts/check-openapi-generated.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
WEB_DIR="$ROOT_DIR/web"
|
||||
API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log"
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
wait "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
(cd "$ROOT_DIR" && docker compose down >/dev/null 2>&1) || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
docker compose up -d --wait postgres redis
|
||||
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run
|
||||
) >"$API_LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if ! curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then
|
||||
echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$WEB_DIR"
|
||||
pnpm run generate-api
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
git diff --exit-code -- web/src/api/generated/schema.d.ts
|
||||
143
scripts/dev_process.py
Normal file
143
scripts/dev_process.py
Normal 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
174
scripts/runtime.sh
Normal 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
110
scripts/smoke-test.sh
Executable 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,4 +34,14 @@ public class MeController extends BaseApiController {
|
|||
|
||||
return ok("response.success.read", mySkillAppService.listMySkills(principal.userId()));
|
||||
}
|
||||
|
||||
@GetMapping("/stars")
|
||||
public ApiResponse<List<SkillSummaryResponse>> listMyStars(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
|
||||
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillActionRequest(String reason) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillMutationResponse(
|
||||
Long skillId,
|
||||
Long versionId,
|
||||
String action,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MergeInitiateRequest(
|
||||
@NotBlank(message = "待合并账号标识不能为空")
|
||||
String secondaryIdentifier
|
||||
) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record MergeInitiateResponse(
|
||||
Long mergeRequestId,
|
||||
String secondaryUserId,
|
||||
String verificationToken,
|
||||
String expiresAt
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue