mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
Merge branch 'main' into feat-0315
# Conflicts: # server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java # server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java # server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java # server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java # server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java # server/skillhub-app/src/main/resources/application.yml # server/skillhub-app/src/main/resources/messages.properties # server/skillhub-app/src/main/resources/messages_zh.properties # server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java # server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java # server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java # web/src/api/client.ts # web/src/i18n/locales/en.json # web/src/i18n/locales/zh.json # web/src/pages/dashboard/reviews.tsx # web/src/pages/settings/profile.tsx # web/src/shared/components/user-menu.tsx
This commit is contained in:
commit
0b1fe6c77c
376 changed files with 4293 additions and 832 deletions
6
Makefile
6
Makefile
|
|
@ -170,6 +170,12 @@ build-backend: ## 构建后端
|
|||
test-backend: ## 运行后端单元测试
|
||||
cd server && JDK_JAVA_OPTIONS="$(BACKEND_TEST_JAVA_OPTIONS)" ./mvnw test
|
||||
|
||||
build-backend-app: ## 构建 skillhub-app 及其依赖模块
|
||||
cd server && ./mvnw -pl skillhub-app -am clean package -DskipTests
|
||||
|
||||
test-backend-app: ## 运行 skillhub-app 及其依赖模块测试
|
||||
cd server && JDK_JAVA_OPTIONS="$(BACKEND_TEST_JAVA_OPTIONS)" ./mvnw -pl skillhub-app -am test
|
||||
|
||||
build: build-backend build-frontend ## 完整构建前后端
|
||||
|
||||
test: test-backend test-frontend ## 运行前后端完整单元测试
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -123,6 +123,20 @@ make dev-all-reset
|
|||
|
||||
Run `make help` to see all available commands.
|
||||
|
||||
Useful backend commands:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make test-backend-app
|
||||
make build-backend-app
|
||||
```
|
||||
|
||||
Do not run `./mvnw -pl skillhub-app clean test` directly under `server/`.
|
||||
`skillhub-app` depends on sibling modules in the same repo, and a standalone clean build
|
||||
can fall back to stale artifacts from the local Maven repository, which surfaces misleading
|
||||
`cannot find symbol` and signature-mismatch errors. Use `-am`, or the `make test-backend-app`
|
||||
and `make build-backend-app` targets above.
|
||||
|
||||
For the full development workflow (local dev → staging → PR), see [docs/dev-workflow.md](docs/dev-workflow.md).
|
||||
|
||||
### API Contract Sync
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ make dev-web # 仅前端
|
|||
```bash
|
||||
make help # 显示所有可用命令
|
||||
make test # 运行后端测试
|
||||
make test-backend-app # 运行 skillhub-app 及其依赖模块测试
|
||||
make build-backend-app # 构建 skillhub-app 及其依赖模块
|
||||
make typecheck-web # TypeScript 类型检查
|
||||
make build-web # 构建前端
|
||||
make generate-api # 重新生成 OpenAPI 类型
|
||||
|
|
@ -117,6 +119,8 @@ make generate-api # 重新生成 OpenAPI 类型
|
|||
./scripts/smoke-test.sh http://localhost:8080 # 运行冒烟测试
|
||||
```
|
||||
|
||||
说明:不要在 `server/` 下直接执行 `./mvnw -pl skillhub-app clean test`。`skillhub-app` 依赖同仓库的 sibling modules,单独 clean 构建时会回退到本地 Maven 仓库里的旧产物并出现大量 `cannot find symbol` / 签名不匹配错误。需要使用 `-am`,或者直接使用上面的 `make test-backend-app` / `make build-backend-app`。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@
|
|||
| title | varchar(256) | |
|
||||
| summary | varchar(512) | |
|
||||
| keywords | varchar(512) | |
|
||||
| search_text | text | SKILL.md 正文 + frontmatter 拼接 |
|
||||
| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
|
||||
| visibility | enum | 冗余,避免搜索时 join |
|
||||
| status | enum | |
|
||||
| updated_at | datetime | |
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ WHERE (visibility = 'PUBLIC')
|
|||
| title | varchar(256) | |
|
||||
| summary | varchar(512) | |
|
||||
| keywords | varchar(512) | |
|
||||
| search_text | text | SKILL.md 正文 + frontmatter 拼接 |
|
||||
| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
|
||||
| visibility | enum | 冗余,避免搜索时 join |
|
||||
| status | enum | |
|
||||
| updated_at | datetime | |
|
||||
|
|
|
|||
295
docs/17-backend-annotation-findings.md
Normal file
295
docs/17-backend-annotation-findings.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Backend Structure Findings During Annotation Pass
|
||||
|
||||
This document records architecture and structure issues that became consistently visible while enriching backend comments. The goal is to preserve concrete observations discovered during code reading, not to propose a full redesign.
|
||||
|
||||
## Status Update (2026-03-19)
|
||||
|
||||
This document was re-checked after the refactor branch work for findings 1, 2, and 4.
|
||||
|
||||
- Finding 1 is now handled in code.
|
||||
- Finding 2 is partially handled in code.
|
||||
- Finding 4 is now handled in code.
|
||||
|
||||
Validation completed on the standard regression path:
|
||||
|
||||
- `make test`
|
||||
- backend Maven tests: `208` passed
|
||||
- frontend Vitest tests: `61` passed
|
||||
|
||||
Double-check notes:
|
||||
|
||||
- The admin-user refactor removed an overlapping, unused application service rather than changing the controller-facing workflow owner.
|
||||
- The namespace and skill-lifecycle refactors moved orchestration out of controllers, but preserved the same downstream domain-service calls, request parameters, audit fields, response message keys, and mutation response shapes.
|
||||
- The security refactor centralized route metadata into one registry, but preserved the same route authorization rules, API-token scope behavior, and CSRF-ignore behavior.
|
||||
- `AuthContextFilter` is now scoped to API paths when projecting request attributes. This narrows unnecessary work on non-API requests, but it does not change existing business behavior because `userId` and `userNsRoles` consumers are API-side controllers and interceptors.
|
||||
|
||||
## 1. Admin user management is split across overlapping application services
|
||||
|
||||
Status: handled on branch `docs/backend-annotation-findings-discussion`
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Both services sit in the application layer and are named as if they own the same capability.
|
||||
- The naming does not make the responsibility boundary obvious to a reader.
|
||||
- This increases the chance that new admin-user use cases get placed inconsistently.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Either consolidate them into one application service, or split them with an explicit boundary such as query vs. command, or account governance vs. account operations.
|
||||
|
||||
Current state:
|
||||
|
||||
- `AdminUserManagementService` has been removed.
|
||||
- `UserManagementController` continues to use `AdminUserAppService` as the single application-service entry point.
|
||||
- Behavior review found no business-logic drift here because the deleted service had no active controller call path.
|
||||
|
||||
## 2. Several controllers still perform orchestration that belongs in application services
|
||||
|
||||
Status: partially handled on branch `docs/backend-annotation-findings-discussion`
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Some controllers coordinate multiple repositories, domain services, request-derived identities, and response assembly in one place.
|
||||
- The controller layer is therefore carrying request translation and business workflow orchestration at the same time.
|
||||
- This makes endpoint behavior harder to reuse, test, and document consistently.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Move multi-step orchestration into dedicated application services and keep controllers focused on transport concerns.
|
||||
|
||||
Current state:
|
||||
|
||||
- `NamespaceController` has been slimmed down by moving orchestration into `NamespacePortalQueryAppService` and `NamespacePortalCommandAppService`.
|
||||
- `SkillLifecycleController` has been slimmed down by moving orchestration into `SkillLifecycleAppService`.
|
||||
- This branch preserved the original domain-service calls and response contracts for the refactored endpoints.
|
||||
- `ReviewController`, `PromotionController`, and `ClawHubCompatController` still exhibit the same structural issue and remain future work.
|
||||
|
||||
## 3. Compatibility endpoints are tightly coupled to canonical domain and repository internals
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The compatibility layer pulls from repositories, domain services, and DTO-mapping concerns at the same time.
|
||||
- The layer is useful, but it is not isolated enough to act as a clean anti-corruption boundary.
|
||||
- Changes in canonical read models or publish flows are more likely to leak into compatibility code.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Treat compatibility support as a dedicated adapter layer with narrower upstream contracts and fewer direct repository dependencies.
|
||||
|
||||
## 4. Security route policy is spread across configuration and implementation classes
|
||||
|
||||
Status: handled on branch `docs/backend-annotation-findings-discussion`
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Route access rules, token-scope rules, and request-context projection are all related to request authorization, but they are not expressed from one central policy model.
|
||||
- A reader has to jump across modules to reconstruct how one API route is actually protected.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Centralize route policy metadata or at least define one authoritative mapping between path patterns, authentication modes, and scope requirements.
|
||||
|
||||
Current state:
|
||||
|
||||
- Route metadata is now centralized in `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java`.
|
||||
- `SecurityConfig`, `ApiTokenScopeService`, and `AuthContextFilter` now depend on that shared registry instead of maintaining separate route lists.
|
||||
- Double-check review confirmed that the refactor preserved the previous access model while removing duplication.
|
||||
|
||||
## 5. Governance behavior is distributed across multiple services without one clear workflow owner
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Governance rules are present in the right domain areas, but the end-to-end moderation and publishing workflow is distributed.
|
||||
- Readers need to reconstruct lifecycle rules by navigating several services and controllers.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Keep the domain split, but introduce a clearer workflow owner or workflow-facing facade for governance use cases.
|
||||
|
||||
## 6. Search-related read paths are split in a way that is hard to follow at first glance
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The codebase has a sensible separation between search, application assembly, and canonical detail reads, but the naming alone does not make their responsibilities obvious.
|
||||
- New contributors may need several passes to understand which service is the authoritative entry point for each read scenario.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Clarify the boundary in naming or package-level docs, especially around "search result assembly" vs. "authoritative skill detail query."
|
||||
|
||||
## 7. Event-driven counter maintenance is useful but not yet modeled as a distinct projection concern
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Listeners are maintaining derived counters, which is a legitimate pattern.
|
||||
- The projection/update responsibility is implicit rather than explicitly named as a read-model maintenance concern.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Consider naming this area more explicitly as projection maintenance or read-model synchronization if the pattern continues to grow.
|
||||
|
||||
## 8. Exception modeling is duplicated across application, domain, and auth layers
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The codebase uses localized error codes consistently, which is good, but several layers define parallel exception abstractions with overlapping semantics.
|
||||
- The global exception handler then has to understand each branch separately.
|
||||
- This makes it harder to tell whether a new business error belongs to the app layer, the auth layer, or the shared domain exception model.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Keep layer-specific exception types only where they represent a real boundary, and consider converging on a smaller shared contract for localized API-facing errors.
|
||||
|
||||
## 9. Repository and read-model access patterns are mixed across layers
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/*/*Repository.java`
|
||||
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/*`
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Some flows use domain repository ports, some use infra JPA repositories, and some app-layer read logic uses `EntityManager` directly.
|
||||
- This is not wrong in itself, but the conventions are not explicit, so contributors have to infer when bypassing the domain port layer is acceptable.
|
||||
- The mixed style increases the chance that query behavior and write behavior evolve under different architectural rules.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Define explicit rules for when a use case should depend on domain repository ports, dedicated query repositories, or direct persistence adapters.
|
||||
|
||||
## 10. OAuth login behavior is decomposed into many small classes without one visible flow owner
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The current decomposition is modular, but understanding one OAuth login request still requires following state across request resolution, provider-specific claim extraction, access-policy evaluation, account provisioning, and redirect handling.
|
||||
- The extension points are good, yet the absence of one flow-oriented facade or documented orchestration path increases onboarding cost.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Keep the provider-specific strategy types, but consider a clearer flow owner or a compact architecture note that names the stages of the OAuth pipeline.
|
||||
|
||||
## 11. Some domain repository ports leak Spring Data pagination types
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Several domain-facing repository contracts use `Page` and `Pageable` directly.
|
||||
- This makes the domain boundary more dependent on Spring Data semantics than on a framework-neutral query model.
|
||||
- It is workable, but it weakens the separation between domain contracts and persistence tooling.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Either accept Spring Data as an intentional part of the domain boundary and document that choice, or introduce domain-oriented page/query abstractions where long-term isolation matters.
|
||||
|
||||
## 12. The auth module follows a more direct JPA style than the business-domain modules
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java`
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java`
|
||||
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The auth area usually talks directly to Spring Data JPA repositories over auth entities.
|
||||
- The business-domain area more often exposes domain repository ports and implements them through infra adapters.
|
||||
- Both styles are valid, but using them side by side without an explicit rationale makes the overall architecture feel uneven.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Decide whether auth is intentionally allowed to stay as a more direct persistence-oriented module, and document that distinction so contributors know which style to apply in new code.
|
||||
|
||||
## 13. Many domain objects double as persistence entities instead of being isolated from JPA concerns
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java`
|
||||
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java`
|
||||
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java`
|
||||
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The codebase often uses the same classes as both domain models and JPA persistence entities.
|
||||
- This keeps implementation compact, but it also means persistence annotations, lazy-loading behavior, and storage-driven shape decisions can leak into domain modeling concerns.
|
||||
- Combined with the repository-style differences already noted above, the codebase can feel partly domain-driven and partly persistence-driven depending on the module.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- If this is an intentional tradeoff, document it clearly as the project's default. Otherwise, consider introducing stronger separation only in areas where persistence concerns are starting to distort domain logic.
|
||||
|
||||
## Priority Recommendation
|
||||
|
||||
If only a small amount of structural cleanup is feasible, the highest-value items are:
|
||||
|
||||
1. Reduce controller orchestration by introducing a few focused application services.
|
||||
2. Clarify the admin-user service boundary.
|
||||
3. Centralize security route policy so access behavior is easier to reason about.
|
||||
123
docs/18-frontend-annotation-findings.md
Normal file
123
docs/18-frontend-annotation-findings.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Frontend Structure Findings During Annotation Pass
|
||||
|
||||
This document records concrete structure and architecture issues noticed while enriching comments in the front-end codebase. The goal is to preserve observations that repeatedly affected code readability, not to prescribe a full rewrite.
|
||||
|
||||
## 1. The shared query layer has become a cross-feature kitchen sink
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/shared/hooks/use-skill-queries.ts`
|
||||
- `web/src/features/skill/use-skill-detail.ts`
|
||||
- `web/src/features/namespace/use-namespace-detail.ts`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- One shared hook file currently owns search, skill detail, version reads, namespace membership, publishing, and promotion-related mutations.
|
||||
- Several feature modules then re-export pieces of that shared file, which hides the real dependency direction.
|
||||
- This makes the boundary between `shared` and `features` feel inverted.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Split the file by feature slice or by backend resource area, and keep feature-facing hooks owned by their feature directories.
|
||||
|
||||
## 2. The router is a large centralized registry with route policy mixed into route declarations
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/app/router.tsx`
|
||||
- `web/src/shared/components/role-guard.tsx`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Route creation, auth guards, role checks, search validation, and lazy-loading rules are all declared in one large module.
|
||||
- This works, but it increases the cost of changing one route because all route concerns are concentrated in a single file.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Keep one router entry point, but consider splitting route definitions by area such as public, dashboard, admin, and settings.
|
||||
|
||||
## 3. Some pages still do too much orchestration instead of delegating to feature-level containers
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/pages/landing.tsx`
|
||||
- `web/src/pages/search.tsx`
|
||||
- `web/src/pages/skill-detail.tsx`
|
||||
- `web/src/pages/dashboard.tsx`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Several pages coordinate multiple hooks, query invalidation, local UI state, navigation rules, and derived presentation decisions.
|
||||
- The page layer is therefore acting as route entry point and business container at the same time.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Move heavier orchestration into feature containers or page-specific hooks so the page files mainly compose them.
|
||||
|
||||
## 4. Feature boundaries are uneven across the codebase
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/features/*`
|
||||
- `web/src/shared/hooks/use-skill-queries.ts`
|
||||
- `web/src/shared/lib/*`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- Some concerns are organized cleanly by feature, while others remain in shared folders even though they are domain-specific.
|
||||
- This makes it harder to predict where new logic should live.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Tighten the rule for what qualifies as `shared`: generic UI, generic hooks, and framework glue should stay there; business-specific query logic should usually live under `features`.
|
||||
|
||||
## 5. Runtime configuration bootstrapping relies on a global window contract
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/bootstrap.ts`
|
||||
- `web/src/api/client.ts`
|
||||
- `web/public/runtime-config.js`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- The current approach is pragmatic for deploy-time configuration, but it couples startup and API behavior to a mutable global object on `window`.
|
||||
- That contract is easy to miss because its definition is spread across bootstrap and API code.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Keep the mechanism if deploy-time injection is required, but document the lifecycle clearly or wrap it behind a dedicated runtime-config module.
|
||||
|
||||
## 6. Some feature modules are only thin re-export layers over shared hooks
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/features/skill/use-skill-detail.ts`
|
||||
- `web/src/features/namespace/use-namespace-detail.ts`
|
||||
- `web/src/features/namespace/use-namespace-members.ts`
|
||||
- `web/src/features/publish/use-publish-skill.ts`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- These files preserve a feature-oriented import path, but they do not own the actual logic.
|
||||
- The real behavior still lives in shared modules, which weakens the meaning of the feature boundary.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Either move the implementation into the feature modules or import the shared hooks directly; keeping both layers long term adds indirection without much value.
|
||||
|
||||
## 7. Some generic dashboard widgets still contain workflow-specific routing rules
|
||||
|
||||
Observed files:
|
||||
|
||||
- `web/src/features/governance/governance-inbox.tsx`
|
||||
- `web/src/features/governance/governance-notifications.tsx`
|
||||
|
||||
Why this stands out:
|
||||
|
||||
- These components look presentation-oriented, but they still know how review, promotion, report, and skill routes map onto dashboard URLs.
|
||||
- That means route policy is now split between the central router and a few feature widgets.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
- Consider moving item-to-route resolution into a dedicated governance navigation helper or feature hook, so the visual components stay closer to pure rendering.
|
||||
|
|
@ -11,8 +11,9 @@ description: 搜索和筛选技能
|
|||
在搜索框输入关键词,SkillHub 会在以下字段中搜索:
|
||||
- 技能名称
|
||||
- 技能描述
|
||||
- SKILL.md 正文内容
|
||||
- 关键词
|
||||
- 技能 slug
|
||||
- frontmatter 中除 `name`、`description`、`version` 外的其他字段
|
||||
- `keywords` / `tags` 等关键词字段
|
||||
|
||||
## 筛选条件
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ description: Search and filter skills
|
|||
Enter keywords in the search box, SkillHub searches in the following fields:
|
||||
- Skill name
|
||||
- Skill description
|
||||
- SKILL.md body content
|
||||
- Keywords
|
||||
- Skill slug
|
||||
- Frontmatter fields other than `name`, `description`, and `version`
|
||||
- Keyword-style fields such as `keywords` and `tags`
|
||||
|
||||
## Filter Conditions
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import org.springframework.boot.SpringApplication;
|
|||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Main Spring Boot entry point for the SkillHub backend application.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(ProfileModerationProperties.class)
|
||||
public class SkillhubApplication {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package com.iflytek.skillhub.bootstrap;
|
|||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Configuration properties for bootstrapping a default admin account in controlled environments.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.bootstrap.admin")
|
||||
public class BootstrapAdminProperties {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import org.springframework.context.annotation.Profile;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Seeds predictable users, memberships, and admin roles for the local development profile.
|
||||
*/
|
||||
@Component
|
||||
@Profile("local")
|
||||
public class LocalDevDataInitializer implements ApplicationRunner {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Startup initializers that prepare local development data and required system
|
||||
* accounts before the application begins serving traffic.
|
||||
*/
|
||||
package com.iflytek.skillhub.bootstrap;
|
||||
|
|
@ -43,6 +43,10 @@ import java.time.ZoneOffset;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Compatibility controller that exposes SkillHub content using ClawHub-style routes and payload
|
||||
* shapes expected by legacy clients.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class ClawHubCompatController {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Facade that assembles registry-style compatibility responses from the platform's canonical search
|
||||
* and skill services.
|
||||
*/
|
||||
@Component
|
||||
public class ClawHubRegistryFacade {
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Declares a dedicated stateless security chain for public compatibility endpoints used by
|
||||
* registry-style clients.
|
||||
*/
|
||||
@Configuration
|
||||
public class ClawHubRegistrySecurityConfig {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
/**
|
||||
* Canonical namespace-and-slug pair used by compatibility adapters to address one skill.
|
||||
*/
|
||||
public record SkillCoordinate(String namespace, String slug) {}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Serves well-known compatibility metadata used by external clients to discover the API base.
|
||||
*/
|
||||
@RestController
|
||||
public class WellKnownController {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* DTOs dedicated to compatibility controllers so legacy response contracts do
|
||||
* not leak into the primary application API surface.
|
||||
*/
|
||||
package com.iflytek.skillhub.compat.dto;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Compatibility endpoints and helpers that expose SkillHub data using
|
||||
* conventions expected by external or legacy clients.
|
||||
*/
|
||||
package com.iflytek.skillhub.compat;
|
||||
|
|
@ -8,6 +8,10 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
/**
|
||||
* Enables asynchronous event handling and other background execution features used by the
|
||||
* application module.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import org.springframework.context.annotation.Configuration;
|
|||
|
||||
import java.time.Clock;
|
||||
|
||||
/**
|
||||
* Wires application-level Spring beans that adapt configurable infrastructure into domain-facing
|
||||
* ports.
|
||||
*/
|
||||
@Configuration
|
||||
public class DomainBeanConfig {
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import org.springframework.context.annotation.Configuration;
|
|||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpenAPI metadata configuration for generated API documentation.
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import org.springframework.context.annotation.Configuration;
|
|||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Registers MVC interceptors related to request rate limiting.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcRateLimitConfig implements WebMvcConfigurer {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Spring configuration properties and lightweight application wiring for the
|
||||
* web layer.
|
||||
*/
|
||||
package com.iflytek.skillhub.config;
|
||||
|
|
@ -16,6 +16,10 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Endpoints for initiating, verifying, and confirming account merge flows
|
||||
* across multiple identities owned by the same user.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/account/merge")
|
||||
public class AccountMergeController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ import java.util.List;
|
|||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Authentication-facing HTTP endpoints.
|
||||
*
|
||||
* <p>This controller keeps transport concerns at the boundary and delegates the
|
||||
* actual authentication, session bootstrap, and direct-login workflows to
|
||||
* dedicated application or auth services.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class AuthController extends BaseApiController {
|
||||
|
|
@ -68,6 +75,10 @@ public class AuthController extends BaseApiController {
|
|||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authenticated principal and refreshes the session if
|
||||
* the persisted user state has diverged from the in-session snapshot.
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<AuthMeResponse> me(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
Authentication authentication,
|
||||
|
|
@ -103,18 +114,32 @@ public class AuthController extends BaseApiController {
|
|||
return ok("response.success.read", AuthMeResponse.from(principal));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists browser-based authentication providers that can initiate an OAuth
|
||||
* login flow for the current client.
|
||||
*/
|
||||
@GetMapping("/providers")
|
||||
public ApiResponse<List<AuthProviderResponse>> providers(
|
||||
@RequestParam(name = "returnTo", required = false) String returnTo) {
|
||||
return ok("response.success.read", authMethodCatalog.listOAuthProviders(returnTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all authentication methods exposed to the UI, including direct and
|
||||
* OAuth-based flows.
|
||||
*/
|
||||
@GetMapping("/methods")
|
||||
public ApiResponse<List<AuthMethodResponse>> methods(
|
||||
@RequestParam(name = "returnTo", required = false) String returnTo) {
|
||||
return ok("response.success.read", authMethodCatalog.listMethods(returnTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds an authenticated session from an upstream identity assertion.
|
||||
*
|
||||
* <p>This endpoint is used by trusted frontends or gateway flows that have
|
||||
* already authenticated the user elsewhere.
|
||||
*/
|
||||
@PostMapping("/session/bootstrap")
|
||||
@RateLimit(category = "auth-session-bootstrap", authenticated = 30, anonymous = 15, windowSeconds = 60)
|
||||
public ApiResponse<AuthMeResponse> bootstrapSession(@Valid @RequestBody SessionBootstrapRequest request,
|
||||
|
|
@ -125,6 +150,10 @@ public class AuthController extends BaseApiController {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a direct-login flow and establishes a first-party web session on
|
||||
* success.
|
||||
*/
|
||||
@PostMapping("/direct/login")
|
||||
@RateLimit(category = "auth-direct-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
|
||||
public ApiResponse<AuthMeResponse> directLogin(@Valid @RequestBody DirectLoginRequest request,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package com.iflytek.skillhub.controller;
|
|||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
|
||||
/**
|
||||
* Minimal controller base class that centralizes access to the standard API response factory.
|
||||
*/
|
||||
public abstract class BaseApiController {
|
||||
|
||||
private final ApiResponseFactory responseFactory;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* API endpoints for the CLI-style device authorization flow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth/device")
|
||||
public class DeviceAuthController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Browser-side endpoint that lets an authenticated user authorize a pending
|
||||
* device code and records the operation in the audit log.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/device")
|
||||
public class DeviceAuthWebController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Minimal liveness endpoint used by tests, probes, and basic uptime checks.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class HealthController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* HTTP endpoints for local account registration, login, and password changes.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth/local")
|
||||
public class LocalAuthController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import org.springframework.web.bind.annotation.*;
|
|||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Self-service API token management endpoints for authenticated users.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/tokens")
|
||||
public class TokenController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.MDC;
|
||||
import com.iflytek.skillhub.search.SearchRebuildService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Administrative maintenance endpoints for search-index operations reserved for super administrators.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/search")
|
||||
public class AdminSearchController extends BaseApiController {
|
||||
|
||||
private final SearchRebuildService searchRebuildService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public AdminSearchController(ApiResponseFactory responseFactory,
|
||||
SearchRebuildService searchRebuildService,
|
||||
AuditLogService auditLogService) {
|
||||
super(responseFactory);
|
||||
this.searchRebuildService = searchRebuildService;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
@PostMapping("/rebuild")
|
||||
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||
public ApiResponse<Void> rebuildAll(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
searchRebuildService.rebuildAll();
|
||||
auditLogService.record(
|
||||
principal.userId(),
|
||||
"REBUILD_SEARCH_INDEX",
|
||||
"SEARCH_INDEX",
|
||||
null,
|
||||
MDC.get("requestId"),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
"{\"scope\":\"ALL\"}"
|
||||
);
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Administrative skill-governance endpoints reserved for platform-level
|
||||
* moderation actions such as hide and unhide.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/skills")
|
||||
public class AdminSkillController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Administrative endpoints for reviewing and resolving user-submitted skill
|
||||
* reports.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/skill-reports")
|
||||
public class AdminSkillReportController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import org.springframework.web.bind.annotation.*;
|
|||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Read-only audit log endpoints for auditors and super administrators.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/audit-logs")
|
||||
public class AuditLogController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Administrative endpoints for listing users and mutating user roles or
|
||||
* account status.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/users")
|
||||
public class UserManagementController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Administrative controllers that expose platform-level management operations
|
||||
* such as audit access, moderation, and user governance.
|
||||
*/
|
||||
package com.iflytek.skillhub.controller.admin;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* HTTP controllers for authentication, profile management, and public API
|
||||
* endpoints that do not belong to a more specialized sub-area.
|
||||
*/
|
||||
package com.iflytek.skillhub.controller;
|
||||
|
|
@ -23,6 +23,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Portal endpoints that expose governance dashboards, inbox items, activity,
|
||||
* and user-facing governance notifications.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/governance", "/api/web/governance"})
|
||||
public class GovernanceController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Portal endpoints scoped to the current authenticated user, such as owned and
|
||||
* starred skill listings.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/me", "/api/web/me"})
|
||||
public class MeController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -2,104 +2,80 @@ package com.iflytek.skillhub.controller.portal;
|
|||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.dto.*;
|
||||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.MemberRequest;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.MyNamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.NamespacePortalCommandAppService;
|
||||
import com.iflytek.skillhub.service.NamespacePortalQueryAppService;
|
||||
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Namespace portal endpoints for discovery, membership management, and
|
||||
* namespace governance operations.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1", "/api/web"})
|
||||
public class NamespaceController extends BaseApiController {
|
||||
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceMemberService namespaceMemberService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceGovernanceService namespaceGovernanceService;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
private final NamespacePortalQueryAppService namespacePortalQueryAppService;
|
||||
private final NamespacePortalCommandAppService namespacePortalCommandAppService;
|
||||
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
|
||||
public NamespaceController(NamespaceService namespaceService,
|
||||
NamespaceMemberService namespaceMemberService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceGovernanceService namespaceGovernanceService,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAppService,
|
||||
NamespacePortalCommandAppService namespacePortalCommandAppService,
|
||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceMemberService = namespaceMemberService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceGovernanceService = namespaceGovernanceService;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
this.namespacePortalQueryAppService = namespacePortalQueryAppService;
|
||||
this.namespacePortalCommandAppService = namespacePortalCommandAppService;
|
||||
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces")
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
|
||||
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
|
||||
PageResponse<NamespaceResponse> response = PageResponse.from(namespaces.map(NamespaceResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces")
|
||||
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
return ok("response.success.read", List.of());
|
||||
}
|
||||
|
||||
List<MyNamespaceResponse> response = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
|
||||
.sorted(Comparator.comparing(Namespace::getSlug))
|
||||
.map(namespace -> MyNamespaceResponse.from(namespace, namespaceRoles.get(namespace.getId()), namespaceAccessPolicy))
|
||||
.toList();
|
||||
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(slug, userId, userNsRoles != null ? userNsRoles : Map.of());
|
||||
return ok("response.success.read", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces")
|
||||
public ApiResponse<NamespaceResponse> createNamespace(
|
||||
@Valid @RequestBody NamespaceRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
if (!canCreateNamespace(principal)) {
|
||||
throw new ForbiddenException("error.namespace.create.platformAdminRequired");
|
||||
}
|
||||
|
||||
Namespace namespace = namespaceService.createNamespace(
|
||||
request.slug(),
|
||||
request.displayName(),
|
||||
request.description(),
|
||||
principal.userId()
|
||||
);
|
||||
return ok("response.success.created", NamespaceResponse.from(namespace));
|
||||
}
|
||||
|
||||
private boolean canCreateNamespace(PlatformPrincipal principal) {
|
||||
return principal.platformRoles().contains("SKILL_ADMIN")
|
||||
|| principal.platformRoles().contains("SUPER_ADMIN");
|
||||
return ok("response.success.created",
|
||||
namespacePortalCommandAppService.createNamespace(request, principal));
|
||||
}
|
||||
|
||||
@PutMapping("/namespaces/{slug}")
|
||||
|
|
@ -107,15 +83,8 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String slug,
|
||||
@RequestBody NamespaceRequest request,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Namespace updated = namespaceService.updateNamespace(
|
||||
namespace.getId(),
|
||||
request.displayName(),
|
||||
request.description(),
|
||||
null,
|
||||
userId
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(updated));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.updateNamespace(slug, request, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/freeze")
|
||||
|
|
@ -123,29 +92,23 @@ public class NamespaceController extends BaseApiController {
|
|||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.freezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.freezeNamespace(
|
||||
slug,
|
||||
request,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/unfreeze")
|
||||
public ApiResponse<NamespaceResponse> unfreezeNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.unfreezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.unfreezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/archive")
|
||||
|
|
@ -153,40 +116,31 @@ public class NamespaceController extends BaseApiController {
|
|||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.archiveNamespace(
|
||||
slug,
|
||||
userId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.archiveNamespace(
|
||||
slug,
|
||||
request,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/restore")
|
||||
public ApiResponse<NamespaceResponse> restoreNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.restoreNamespace(
|
||||
slug,
|
||||
userId,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.restoreNamespace(
|
||||
slug,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/members")
|
||||
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
|
||||
Pageable pageable,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.assertMember(namespace.getId(), userId);
|
||||
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
|
||||
PageResponse<MemberResponse> response = PageResponse.from(members.map(MemberResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listMembers(slug, pageable, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/member-candidates")
|
||||
|
|
@ -203,14 +157,8 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String slug,
|
||||
@Valid @RequestBody MemberRequest request,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
NamespaceMember member = namespaceMemberService.addMember(
|
||||
namespace.getId(),
|
||||
request.userId(),
|
||||
request.role(),
|
||||
userId
|
||||
);
|
||||
return ok("response.success.created", MemberResponse.from(member));
|
||||
return ok("response.success.created",
|
||||
namespacePortalCommandAppService.addMember(slug, request.userId(), request.role(), userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/namespaces/{slug}/members/{userId}")
|
||||
|
|
@ -218,9 +166,8 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String slug,
|
||||
@PathVariable("userId") String memberUserId,
|
||||
@RequestAttribute("userId") String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId);
|
||||
return ok("response.success.deleted", new MessageResponse("Member removed successfully"));
|
||||
return ok("response.success.deleted",
|
||||
namespacePortalCommandAppService.removeMember(slug, memberUserId, operatorUserId));
|
||||
}
|
||||
|
||||
@PutMapping("/namespaces/{slug}/members/{userId}/role")
|
||||
|
|
@ -229,13 +176,7 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String userId,
|
||||
@Valid @RequestBody UpdateMemberRoleRequest request,
|
||||
@RequestAttribute("userId") String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
NamespaceMember member = namespaceMemberService.updateMemberRole(
|
||||
namespace.getId(),
|
||||
userId,
|
||||
request.role(),
|
||||
operatorUserId
|
||||
);
|
||||
return ok("response.success.updated", MemberResponse.from(member));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.updateMemberRole(slug, userId, request, operatorUserId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Promotion workflow endpoints that expose submission, review, and query
|
||||
* operations for cross-namespace promotion requests.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/promotions", "/api/web/promotions"})
|
||||
public class PromotionController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Endpoints for submitting, browsing, approving, rejecting, and withdrawing
|
||||
* review tasks.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/reviews", "/api/web/reviews"})
|
||||
public class ReviewController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Read-oriented skill endpoints for detail pages, lifecycle inspection, file
|
||||
* browsing, version resolution, and download delivery.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillController extends BaseApiController {
|
||||
|
|
@ -53,6 +57,10 @@ public class SkillController extends BaseApiController {
|
|||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the viewer-specific projection of a skill, including lifecycle
|
||||
* pointers and interaction permissions derived from the caller context.
|
||||
*/
|
||||
@GetMapping("/{namespace}/{slug}")
|
||||
public ApiResponse<SkillDetailResponse> getSkillDetail(
|
||||
@PathVariable String namespace,
|
||||
|
|
@ -90,6 +98,10 @@ public class SkillController extends BaseApiController {
|
|||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists versions visible to the caller rather than every persisted version
|
||||
* of the skill.
|
||||
*/
|
||||
@GetMapping("/{namespace}/{slug}/versions")
|
||||
public ApiResponse<PageResponse<SkillVersionResponse>> listVersions(
|
||||
@PathVariable String namespace,
|
||||
|
|
@ -120,6 +132,10 @@ public class SkillController extends BaseApiController {
|
|||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns metadata for a concrete version that the current caller is
|
||||
* allowed to inspect.
|
||||
*/
|
||||
@GetMapping("/{namespace}/{slug}/versions/{version}")
|
||||
public ApiResponse<SkillVersionDetailResponse> getVersionDetail(
|
||||
@PathVariable String namespace,
|
||||
|
|
@ -150,6 +166,10 @@ public class SkillController extends BaseApiController {
|
|||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists packaged files for a concrete version after visibility checks have
|
||||
* been applied.
|
||||
*/
|
||||
@GetMapping("/{namespace}/{slug}/versions/{version}/files")
|
||||
public ApiResponse<List<SkillFileResponse>> listFiles(
|
||||
@PathVariable String namespace,
|
||||
|
|
@ -208,6 +228,10 @@ public class SkillController extends BaseApiController {
|
|||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a single packaged file directly from object storage through the
|
||||
* application API.
|
||||
*/
|
||||
@GetMapping("/{namespace}/{slug}/versions/{version}/file")
|
||||
public ResponseEntity<InputStreamResource> getFileContent(
|
||||
@PathVariable String namespace,
|
||||
|
|
@ -254,6 +278,10 @@ public class SkillController extends BaseApiController {
|
|||
.body(new InputStreamResource(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a human-facing version selector to the exact version that would
|
||||
* be downloaded by the caller.
|
||||
*/
|
||||
@GetMapping("/{namespace}/{slug}/resolve")
|
||||
public ApiResponse<ResolveVersionResponse> resolveVersion(
|
||||
@PathVariable String namespace,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,14 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
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.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SkillLifecycleAppService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
|
@ -29,34 +20,20 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Endpoints that mutate skill lifecycle state, including archive, unarchive,
|
||||
* withdraw-review, delete-version, and rerelease operations.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillLifecycleController extends BaseApiController {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
private final ReviewService reviewService;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
private final SkillLifecycleAppService skillLifecycleAppService;
|
||||
|
||||
public SkillLifecycleController(NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
ReviewService reviewService,
|
||||
SkillPublishService skillPublishService,
|
||||
AuditLogService auditLogService,
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
public SkillLifecycleController(SkillLifecycleAppService skillLifecycleAppService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
this.reviewService = reviewService;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
this.skillLifecycleAppService = skillLifecycleAppService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/archive")
|
||||
|
|
@ -66,18 +43,14 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill archived = skillGovernanceService.archiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
request != null ? request.reason() : null
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name()));
|
||||
skillLifecycleAppService.archiveSkill(
|
||||
namespace,
|
||||
slug,
|
||||
request,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/unarchive")
|
||||
|
|
@ -86,17 +59,13 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill restored = skillGovernanceService.unarchiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name()));
|
||||
skillLifecycleAppService.unarchiveSkill(
|
||||
namespace,
|
||||
slug,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{namespace}/{slug}/versions/{version}")
|
||||
|
|
@ -106,20 +75,14 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
skillGovernanceService.deleteVersion(
|
||||
skill,
|
||||
skillVersion,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
return ok("response.success.deleted",
|
||||
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version));
|
||||
skillLifecycleAppService.deleteVersion(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review")
|
||||
|
|
@ -128,23 +91,13 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@PathVariable String version,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
SkillVersion withdrawnVersion = reviewService.withdrawReview(skillVersion.getId(), userId);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
"REVIEW_WITHDRAW",
|
||||
"SKILL_VERSION",
|
||||
skillVersion.getId(),
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
"{\"version\":\"" + version.replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", withdrawnVersion.getStatus().name()));
|
||||
skillLifecycleAppService.withdrawReview(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/versions/{version}/rerelease")
|
||||
|
|
@ -155,44 +108,14 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion(
|
||||
skill.getId(),
|
||||
skillVersion.getVersion(),
|
||||
request.targetVersion().trim(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
"RERELEASE_SKILL_VERSION",
|
||||
"SKILL_VERSION",
|
||||
skillVersion.getId(),
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
"{\"sourceVersion\":\"" + version.replace("\"", "\\\"")
|
||||
+ "\",\"targetVersion\":\"" + request.targetVersion().trim().replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name()));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
skillLifecycleAppService.rereleaseVersion(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
request,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Upload endpoints for skill packages.
|
||||
*
|
||||
* <p>The controller is responsible for archive extraction and request shaping,
|
||||
* while the domain service owns all publication validation and state changes.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillPublishController extends BaseApiController {
|
||||
|
|
@ -37,6 +43,10 @@ public class SkillPublishController extends BaseApiController {
|
|||
this.skillHubMetrics = skillHubMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an uploaded package into the target namespace after archive
|
||||
* extraction and visibility parsing.
|
||||
*/
|
||||
@PostMapping("/{namespace}/publish")
|
||||
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
|
||||
public ApiResponse<PublishResponse> publish(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Endpoints for reading and mutating the current user's rating on a skill.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillRatingController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Endpoints that let authenticated users report a skill for moderation.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillReportController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import org.springframework.web.bind.annotation.*;
|
|||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Portal search endpoint that adapts HTTP query parameters to the search
|
||||
* application service and visibility scope.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/web/skills"})
|
||||
public class SkillSearchController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import com.iflytek.skillhub.domain.social.SkillStarService;
|
|||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Endpoints for starring, unstarring, and checking star state on a skill.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillStarController extends BaseApiController {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Endpoints for reading and mutating named tags that point to skill versions.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({
|
||||
"/api/v1/skills/{namespace}/{slug}/tags",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Primary portal-facing API controllers for namespaces, skills, review flows,
|
||||
* search, and other end-user operations.
|
||||
*/
|
||||
package com.iflytek.skillhub.controller.portal;
|
||||
|
|
@ -14,6 +14,10 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Builds a publishable package model from multipart form uploads while enforcing package safety
|
||||
* and size constraints.
|
||||
*/
|
||||
@Component
|
||||
public class MultipartPackageExtractor {
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import java.util.Set;
|
|||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Extracts zip uploads into validated package entries that can be consumed by the publish flow.
|
||||
*/
|
||||
@Component
|
||||
public class ZipPackageExtractor {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Controller support utilities for multipart parsing, archive extraction, and
|
||||
* other transport-specific request preparation concerns.
|
||||
*/
|
||||
package com.iflytek.skillhub.controller.support;
|
||||
|
|
@ -12,14 +12,14 @@ import jakarta.validation.constraints.Size;
|
|||
* <p>Validation rules for displayName:
|
||||
* <ul>
|
||||
* <li>Length: 2–32 characters (after trim)</li>
|
||||
* <li>Allowed characters: Chinese, English, digits, underscore, hyphen</li>
|
||||
* <li>Allowed characters: Chinese, English, digits, spaces, underscore, hyphen</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param displayName new display name (nullable — omit to leave unchanged)
|
||||
*/
|
||||
public record UpdateProfileRequest(
|
||||
@Size(min = 2, max = 32, message = "error.profile.displayName.length")
|
||||
@Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_-]+$",
|
||||
@Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_ -]+$",
|
||||
message = "error.profile.displayName.pattern")
|
||||
String displayName
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Application DTOs used to keep HTTP request and response contracts separate
|
||||
* from domain entities.
|
||||
*/
|
||||
package com.iflytek.skillhub.dto;
|
||||
|
|
@ -2,6 +2,9 @@ package com.iflytek.skillhub.exception;
|
|||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Application-layer exception mapped to HTTP 400 with a localized error code.
|
||||
*/
|
||||
public class BadRequestException extends LocalizedException {
|
||||
|
||||
public BadRequestException(String messageCode, Object... messageArgs) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.iflytek.skillhub.exception;
|
|||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Application-layer exception mapped to HTTP 403 with a localized error code.
|
||||
*/
|
||||
public class ForbiddenException extends LocalizedException {
|
||||
|
||||
public ForbiddenException(String messageCode, Object... messageArgs) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
|
|||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
|
||||
* error envelope.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.iflytek.skillhub.exception;
|
|||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Common contract for errors that can be rendered as localized API responses.
|
||||
*/
|
||||
public interface LocalizedError {
|
||||
String messageCode();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.iflytek.skillhub.exception;
|
|||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Base class for application-layer exceptions that carry a localized message code and HTTP status.
|
||||
*/
|
||||
public abstract class LocalizedException extends RuntimeException implements LocalizedError {
|
||||
|
||||
private final String messageCode;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.iflytek.skillhub.exception;
|
|||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Application-layer exception mapped to HTTP 401 with a localized error code.
|
||||
*/
|
||||
public class UnauthorizedException extends LocalizedException {
|
||||
|
||||
public UnauthorizedException(String messageCode, Object... messageArgs) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Application-level exception translation and localized error payload support
|
||||
* for the HTTP boundary.
|
||||
*/
|
||||
package com.iflytek.skillhub.exception;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
|
|
@ -23,6 +24,9 @@ import org.springframework.security.web.context.HttpSessionSecurityContextReposi
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Projects the authenticated principal into request attributes consumed by the controller layer.
|
||||
*/
|
||||
@Component
|
||||
public class AuthContextFilter extends OncePerRequestFilter {
|
||||
|
||||
|
|
@ -31,17 +35,20 @@ public class AuthContextFilter extends OncePerRequestFilter {
|
|||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final boolean enforceActiveUserCheck;
|
||||
private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry;
|
||||
|
||||
public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
ApiResponseFactory apiResponseFactory,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck) {
|
||||
@Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck,
|
||||
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.objectMapper = objectMapper;
|
||||
this.enforceActiveUserCheck = enforceActiveUserCheck;
|
||||
this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -49,6 +56,10 @@ public class AuthContextFilter extends OncePerRequestFilter {
|
|||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
PlatformPrincipal principal = resolvePrincipal(request);
|
||||
if (principal != null) {
|
||||
if (isInactiveUser(principal.userId())) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ import java.time.Instant;
|
|||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Prevents duplicate execution of mutating HTTP requests identified by
|
||||
* {@code X-Request-Id}.
|
||||
*
|
||||
* <p>Redis is treated as the fast-path cache, while PostgreSQL remains the
|
||||
* durable source of truth when cache access fails.
|
||||
*/
|
||||
@Component
|
||||
public class IdempotencyInterceptor implements HandlerInterceptor {
|
||||
|
||||
|
|
@ -38,6 +45,10 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
|
|||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects duplicate mutating requests before controller execution and
|
||||
* creates a processing marker for first-seen request identifiers.
|
||||
*/
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
String method = request.getMethod();
|
||||
|
|
@ -94,6 +105,10 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the idempotency record with the observed response status once
|
||||
* request processing has completed.
|
||||
*/
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||
String method = request.getMethod();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
|||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Ensures every request has a request identifier for logs, responses, and downstream audit
|
||||
* correlation.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
|
|
|||
|
|
@ -15,21 +15,32 @@ import org.springframework.web.util.ContentCachingResponseWrapper;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Logs inbound HTTP requests with only core parameters to keep log files compact.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
|
||||
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
private static final int MAX_LOG_BODY_LENGTH = 512;
|
||||
private static final int MAX_LOG_BODY_LENGTH = 200;
|
||||
|
||||
private static final Set<String> SKIP_PREFIXES = Set.of(
|
||||
"/actuator", "/favicon.ico", "/assets/"
|
||||
);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String uri = request.getRequestURI();
|
||||
if (shouldSkip(uri)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentCachingRequestWrapper cachedRequest = new ContentCachingRequestWrapper(request);
|
||||
ContentCachingResponseWrapper cachedResponse = new ContentCachingResponseWrapper(response);
|
||||
|
||||
|
|
@ -49,45 +60,43 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
String queryString = request.getQueryString();
|
||||
String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri;
|
||||
|
||||
String contentType = request.getContentType();
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n========== HTTP Request ==========\n");
|
||||
sb.append("URL: ").append(request.getMethod()).append(" ").append(fullUrl).append("\n");
|
||||
sb.append("Remote Address: ").append(request.getRemoteAddr()).append("\n");
|
||||
sb.append("Headers: ").append(getHeaders(request)).append("\n");
|
||||
sb.append(request.getMethod()).append(" ").append(fullUrl);
|
||||
sb.append(" | ").append(response.getStatus());
|
||||
sb.append(" | ").append(duration).append("ms");
|
||||
sb.append(" | ").append(request.getRemoteAddr());
|
||||
if (contentType != null) {
|
||||
sb.append(" | Content-Type: ").append(contentType);
|
||||
}
|
||||
if (userAgent != null) {
|
||||
sb.append(" | UA: ").append(truncate(userAgent, 80));
|
||||
}
|
||||
|
||||
String requestBody = getRequestBody(request);
|
||||
if (requestBody != null && !requestBody.isBlank()) {
|
||||
sb.append("Request Body: ").append(requestBody).append("\n");
|
||||
sb.append(" | Body: ").append(requestBody);
|
||||
}
|
||||
|
||||
sb.append("Response Status: ").append(response.getStatus()).append("\n");
|
||||
|
||||
String responseBody = getResponseBody(response);
|
||||
if (responseBody != null && !responseBody.isBlank()) {
|
||||
sb.append("Response Body: ").append(responseBody).append("\n");
|
||||
}
|
||||
|
||||
sb.append("Duration: ").append(duration).append("ms\n");
|
||||
sb.append("===================================");
|
||||
|
||||
log.info(sb.toString());
|
||||
}
|
||||
|
||||
private Map<String, String> getHeaders(HttpServletRequest request) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
headers.put(headerName, request.getHeader(headerName));
|
||||
private boolean shouldSkip(String uri) {
|
||||
for (String prefix : SKIP_PREFIXES) {
|
||||
if (uri.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
return false;
|
||||
}
|
||||
|
||||
private String getRequestBody(ContentCachingRequestWrapper request) {
|
||||
byte[] buf = request.getContentAsByteArray();
|
||||
if (buf.length > 0) {
|
||||
try {
|
||||
return truncateBody(new String(buf, request.getCharacterEncoding()));
|
||||
return truncate(new String(buf, request.getCharacterEncoding()), MAX_LOG_BODY_LENGTH);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
return "[unknown encoding]";
|
||||
}
|
||||
|
|
@ -95,23 +104,10 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
return null;
|
||||
}
|
||||
|
||||
private String getResponseBody(ContentCachingResponseWrapper response) {
|
||||
byte[] buf = response.getContentAsByteArray();
|
||||
if (buf.length > 0) {
|
||||
try {
|
||||
return truncateBody(new String(buf, response.getCharacterEncoding()));
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
return "[unknown encoding]";
|
||||
}
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncateBody(String body) {
|
||||
if (body == null || body.length() <= MAX_LOG_BODY_LENGTH) {
|
||||
return body;
|
||||
}
|
||||
return body.substring(0, MAX_LOG_BODY_LENGTH)
|
||||
+ "... [truncated, original length=" + body.length() + "]";
|
||||
return value.substring(0, maxLength) + "...[truncated]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Servlet filters and MVC interceptors that enrich requests with cross-cutting
|
||||
* concerns such as logging, idempotency, and caller context.
|
||||
*/
|
||||
package com.iflytek.skillhub.filter;
|
||||
|
|
@ -7,6 +7,10 @@ import org.springframework.scheduling.annotation.Async;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/**
|
||||
* Updates denormalized skill rating counters when rating events are emitted by
|
||||
* the social domain.
|
||||
*/
|
||||
@Component
|
||||
public class SkillRatingEventListener {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import org.springframework.scheduling.annotation.Async;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/**
|
||||
* Keeps the stored star count in sync with the star/unstar event stream.
|
||||
*/
|
||||
@Component
|
||||
public class SkillStarEventListener {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Application event listeners that react to domain events to update read-side
|
||||
* counters and other eventually consistent projections.
|
||||
*/
|
||||
package com.iflytek.skillhub.listener;
|
||||
|
|
@ -3,6 +3,9 @@ package com.iflytek.skillhub.metrics;
|
|||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Small facade over Micrometer that centralizes metric names and tags used by backend flows.
|
||||
*/
|
||||
@Component
|
||||
public class SkillHubMetrics {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Metrics helpers used to publish application and product telemetry from the
|
||||
* web layer.
|
||||
*/
|
||||
package com.iflytek.skillhub.metrics;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Application-layer orchestration for the SkillHub backend.
|
||||
*
|
||||
* <p>This module adapts HTTP requests, security context, and DTO mapping to the
|
||||
* domain-layer services exposed by the other backend modules.
|
||||
*/
|
||||
package com.iflytek.skillhub;
|
||||
|
|
@ -16,6 +16,10 @@ import javax.crypto.spec.SecretKeySpec;
|
|||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Assigns stable anonymous identities for download rate limiting by combining client IP data with
|
||||
* a signed cookie.
|
||||
*/
|
||||
@Component
|
||||
public class AnonymousDownloadIdentityService {
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import java.util.regex.Matcher;
|
|||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Resolves the best-effort client IP address from proxy-aware request headers.
|
||||
*/
|
||||
@Component
|
||||
public class ClientIpResolver {
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import java.util.Deque;
|
|||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
|
||||
/**
|
||||
* Test-profile rate limiter that keeps sliding-window counters in memory.
|
||||
*/
|
||||
@Component
|
||||
@Profile("test")
|
||||
public class InMemorySlidingWindowRateLimiter implements RateLimiter {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import java.lang.annotation.Retention;
|
|||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Declares per-endpoint rate-limit settings for authenticated and anonymous callers.
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RateLimit {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import org.springframework.web.servlet.HandlerMapping;
|
|||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Enforces the {@link RateLimit} annotation by resolving caller identity and delegating quota
|
||||
* checks to the configured rate limiter implementation.
|
||||
*/
|
||||
@Component
|
||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.iflytek.skillhub.ratelimit;
|
||||
|
||||
/**
|
||||
* Contract for key-based rate limiter implementations used by API interceptors.
|
||||
*/
|
||||
public interface RateLimiter {
|
||||
|
||||
boolean tryAcquire(String key, int limit, int windowSeconds);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import org.springframework.stereotype.Component;
|
|||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* Production rate limiter backed by Redis and a Lua script for atomic sliding-window checks.
|
||||
*/
|
||||
@Component
|
||||
@Profile("!test")
|
||||
public class RedisSlidingWindowRateLimiter implements RateLimiter {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Rate-limiting annotations, interceptors, and implementations used to
|
||||
* protect public APIs from abuse.
|
||||
*/
|
||||
package com.iflytek.skillhub.ratelimit;
|
||||
|
|
@ -18,6 +18,9 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Custom query repository that builds pageable admin-user search results with optional filters.
|
||||
*/
|
||||
@Repository
|
||||
public class AdminUserSearchRepository {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Application-specific query repositories that package read models tailored to
|
||||
* web and administration use cases.
|
||||
*/
|
||||
package com.iflytek.skillhub.repository;
|
||||
|
|
@ -15,6 +15,9 @@ import org.springframework.stereotype.Component;
|
|||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Converts authorization failures on API routes into the platform's standard JSON error envelope.
|
||||
*/
|
||||
@Component
|
||||
public class ApiAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import org.springframework.stereotype.Component;
|
|||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Converts unauthenticated API access attempts into a consistent JSON 401 response.
|
||||
*/
|
||||
@Component
|
||||
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import org.springframework.http.HttpStatus;
|
|||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Tracks repeated authentication failures and throttles abusive identifiers or client addresses.
|
||||
*/
|
||||
@Service
|
||||
public class AuthFailureThrottleService {
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import java.util.stream.Collectors;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Applies lightweight redaction rules before sensitive strings are written to logs.
|
||||
*/
|
||||
@Component
|
||||
public class SensitiveLogSanitizer {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Web security helpers that translate authorization failures, sanitize logs,
|
||||
* and coordinate security-specific application behavior.
|
||||
*/
|
||||
package com.iflytek.skillhub.security;
|
||||
|
|
@ -13,6 +13,10 @@ import java.time.Instant;
|
|||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Read-only application service that queries audit logs with dynamic filtering
|
||||
* tailored to administration screens.
|
||||
*/
|
||||
@Service
|
||||
public class AdminAuditLogAppService {
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import java.util.stream.Collectors;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Application service that enriches raw skill report records with skill and
|
||||
* namespace context required by admin UIs.
|
||||
*/
|
||||
@Service
|
||||
public class AdminSkillReportAppService {
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ import java.util.TreeSet;
|
|||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Administrative user-management application service built around the main
|
||||
* search and mutation use cases exposed by the admin API.
|
||||
*/
|
||||
@Service
|
||||
public class AdminUserAppService {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
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.TreeSet;
|
||||
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);
|
||||
roles = new LinkedHashSet<>(withDefaultUserRole(roles));
|
||||
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 Set<String> withDefaultUserRole(Set<String> roles) {
|
||||
Set<String> resolvedRoles = new TreeSet<>();
|
||||
if (roles != null) {
|
||||
resolvedRoles.addAll(roles);
|
||||
}
|
||||
if (resolvedRoles.isEmpty()) {
|
||||
resolvedRoles.add("USER");
|
||||
}
|
||||
return Set.copyOf(resolvedRoles);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Transport-level audit fields extracted from the current HTTP request.
|
||||
*/
|
||||
public record AuditRequestContext(
|
||||
String clientIp,
|
||||
String userAgent
|
||||
) {
|
||||
public static AuditRequestContext from(HttpServletRequest request) {
|
||||
return new AuditRequestContext(
|
||||
request != null ? request.getRemoteAddr() : null,
|
||||
request != null ? request.getHeader("User-Agent") : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ import java.util.List;
|
|||
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Builds the catalog of authentication methods and OAuth providers that the UI
|
||||
* can render dynamically.
|
||||
*/
|
||||
@Service
|
||||
public class AuthMethodCatalog {
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import java.util.Map;
|
|||
import java.util.function.Function;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Dispatches direct-login requests to a configured provider and then binds the
|
||||
* resulting principal to the current HTTP session.
|
||||
*/
|
||||
@Service
|
||||
public class DirectAuthService {
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ import org.springframework.data.domain.Page;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Application-facing aggregation service for the governance workbench.
|
||||
*
|
||||
* <p>It joins review, promotion, report, namespace, and audit sources into the
|
||||
* composite read models consumed by governance screens.
|
||||
*/
|
||||
@Service
|
||||
public class GovernanceWorkbenchAppService {
|
||||
|
||||
|
|
@ -75,6 +81,10 @@ public class GovernanceWorkbenchAppService {
|
|||
this.adminAuditLogAppService = adminAuditLogAppService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns top-level counts for the governance dashboard, scoped by the
|
||||
* caller's namespace and platform roles.
|
||||
*/
|
||||
public GovernanceSummaryResponse getSummary(String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
|
|
@ -89,6 +99,10 @@ public class GovernanceWorkbenchAppService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the governance inbox by combining pending reviews, promotions, and
|
||||
* reports that the caller is allowed to see.
|
||||
*/
|
||||
public PageResponse<GovernanceInboxItemResponse> listInbox(String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
|
|
@ -121,6 +135,10 @@ public class GovernanceWorkbenchAppService {
|
|||
return new PageResponse<>(items.subList(fromIndex, toIndex), items.size(), page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns audit-derived governance activity entries for callers with
|
||||
* platform-wide visibility.
|
||||
*/
|
||||
public PageResponse<GovernanceActivityItemResponse> listActivity(Set<String> platformRoles, int page, int size) {
|
||||
if (!canReadActivity(platformRoles)) {
|
||||
return new PageResponse<>(List.of(), 0, page, size);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue