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:
xiose 2026-03-19 16:20:07 +08:00
commit 0b1fe6c77c
376 changed files with 4293 additions and 832 deletions

View file

@ -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 ## 运行前后端完整单元测试

View file

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

View file

@ -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`。
### 项目结构
```

View file

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

View file

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

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

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

View file

@ -11,8 +11,9 @@ description: 搜索和筛选技能
在搜索框输入关键词,SkillHub 会在以下字段中搜索:
- 技能名称
- 技能描述
- SKILL.md 正文内容
- 关键词
- 技能 slug
- frontmatter 中除 `name`、`description`、`version` 外的其他字段
- `keywords` / `tags` 等关键词字段
## 筛选条件

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
/**
* Spring configuration properties and lightweight application wiring for the
* web layer.
*/
package com.iflytek.skillhub.config;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
/**
* Application DTOs used to keep HTTP request and response contracts separate
* from domain entities.
*/
package com.iflytek.skillhub.dto;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
/**
* Application-level exception translation and localized error payload support
* for the HTTP boundary.
*/
package com.iflytek.skillhub.exception;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
/**
* Metrics helpers used to publish application and product telemetry from the
* web layer.
*/
package com.iflytek.skillhub.metrics;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
/**
* Rate-limiting annotations, interceptors, and implementations used to
* protect public APIs from abuse.
*/
package com.iflytek.skillhub.ratelimit;

View file

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

View file

@ -0,0 +1,5 @@
/**
* Application-specific query repositories that package read models tailored to
* web and administration use cases.
*/
package com.iflytek.skillhub.repository;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
/**
* Web security helpers that translate authorization failures, sanitize logs,
* and coordinate security-specific application behavior.
*/
package com.iflytek.skillhub.security;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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