diff --git a/Makefile b/Makefile index 2f77d71c..43e46a50 100644 --- a/Makefile +++ b/Makefile @@ -170,6 +170,12 @@ build-backend: ## 构建后端 test-backend: ## 运行后端单元测试 cd server && JDK_JAVA_OPTIONS="$(BACKEND_TEST_JAVA_OPTIONS)" ./mvnw test +build-backend-app: ## 构建 skillhub-app 及其依赖模块 + cd server && ./mvnw -pl skillhub-app -am clean package -DskipTests + +test-backend-app: ## 运行 skillhub-app 及其依赖模块测试 + cd server && JDK_JAVA_OPTIONS="$(BACKEND_TEST_JAVA_OPTIONS)" ./mvnw -pl skillhub-app -am test + build: build-backend build-frontend ## 完整构建前后端 test: test-backend test-frontend ## 运行前后端完整单元测试 diff --git a/README.md b/README.md index 692be941..7ca391f1 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,20 @@ make dev-all-reset Run `make help` to see all available commands. +Useful backend commands: + +```bash +make test +make test-backend-app +make build-backend-app +``` + +Do not run `./mvnw -pl skillhub-app clean test` directly under `server/`. +`skillhub-app` depends on sibling modules in the same repo, and a standalone clean build +can fall back to stale artifacts from the local Maven repository, which surfaces misleading +`cannot find symbol` and signature-mismatch errors. Use `-am`, or the `make test-backend-app` +and `make build-backend-app` targets above. + For the full development workflow (local dev → staging → PR), see [docs/dev-workflow.md](docs/dev-workflow.md). ### API Contract Sync diff --git a/README_zh.md b/README_zh.md index ee2e5b6d..7766464c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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`。 + ### 项目结构 ``` diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 61a82975..20e31621 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -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 | | diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 4b38a3eb..e40d6da4 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -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 | | diff --git a/docs/17-backend-annotation-findings.md b/docs/17-backend-annotation-findings.md new file mode 100644 index 00000000..30986a43 --- /dev/null +++ b/docs/17-backend-annotation-findings.md @@ -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. diff --git a/docs/18-frontend-annotation-findings.md b/docs/18-frontend-annotation-findings.md new file mode 100644 index 00000000..09a7ad5e --- /dev/null +++ b/docs/18-frontend-annotation-findings.md @@ -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. diff --git a/document/docs/03-user-guide/discovery/search.md b/document/docs/03-user-guide/discovery/search.md index 6a457ff9..01da8f73 100644 --- a/document/docs/03-user-guide/discovery/search.md +++ b/document/docs/03-user-guide/discovery/search.md @@ -11,8 +11,9 @@ description: 搜索和筛选技能 在搜索框输入关键词,SkillHub 会在以下字段中搜索: - 技能名称 - 技能描述 -- SKILL.md 正文内容 -- 关键词 +- 技能 slug +- frontmatter 中除 `name`、`description`、`version` 外的其他字段 +- `keywords` / `tags` 等关键词字段 ## 筛选条件 diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md index f0c1b3d4..825ef714 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md @@ -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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index 38f33ec6..adb56bca 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java index 11aadcdf..d3de35df 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java index b78a871c..6755beed 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/package-info.java new file mode 100644 index 00000000..7a1e2f4b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index dbd179f4..e689c0c8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 1a697f18..b7fac835 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java index 66a632e9..bbc43dd1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java index f1077287..b5b462c2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java @@ -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) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java index 4ddfb79d..bc505f49 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/package-info.java new file mode 100644 index 00000000..6b9d1a59 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/package-info.java new file mode 100644 index 00000000..3b2ae1cb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java index b7b9086e..8e921cd4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 78fa6199..7b36265f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java index 214f7322..c285b676 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java index ca84cebd..0a7bfd93 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/package-info.java new file mode 100644 index 00000000..2a70c248 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/package-info.java @@ -0,0 +1,5 @@ +/** + * Spring configuration properties and lightweight application wiring for the + * web layer. + */ +package com.iflytek.skillhub.config; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java index 7e812f96..b7e1386e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 76b62a92..1552f6f2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -38,6 +38,13 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +/** + * Authentication-facing HTTP endpoints. + * + *

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 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> 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> 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. + * + *

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 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 directLogin(@Valid @RequestBody DirectLoginRequest request, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java index 51a49de7..62adb567 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java index 02f7d311..1913723c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index aec99cf1..c2f47d4c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java index 5d3bf3a1..f1d0d7c0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index daea30e0..8d6f5e4f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index 673f3544..19115087 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java new file mode 100644 index 00000000..b6dc264a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java @@ -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 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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java index 708cfdb8..077538a9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java index 232ed8c0..e779615d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 2616ea7e..ed8dc624 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 850ecc26..efaf7647 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/package-info.java new file mode 100644 index 00000000..9b9ca139 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/package-info.java new file mode 100644 index 00000000..3662e031 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java index e14e4ea7..591371e5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index dd94be4b..a9d7926f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 52f14913..c6f5b267 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -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> listNamespaces(Pageable pageable) { - Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); - PageResponse 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> listMyNamespaces( @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); - if (namespaceRoles.isEmpty()) { - return ok("response.success.read", List.of()); - } - - List 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 getNamespace(@PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map 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 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 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 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> listMembers(@PathVariable String slug, Pageable pageable, @RequestAttribute("userId") String userId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.assertMember(namespace.getId(), userId); - Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); - PageResponse 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)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 5617f21c..e86f7dbf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 54a9ca7d..9eaf1dc8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 217804f3..3c0f87b8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -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 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> 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 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> 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 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 resolveVersion( @PathVariable String namespace, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index adcc8224..4f07ffd8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -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 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 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 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 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))); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index ced3ec4d..c853ca32 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -19,6 +19,12 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; +/** + * Upload endpoints for skill packages. + * + *

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 publish( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index 88a48ca6..8ad74f97 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java index c6180ef6..740bb890 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index a3786170..4f99473a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index 8ffb88a9..5d95837e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index 1edd7036..6389ffa8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -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", diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/package-info.java new file mode 100644 index 00000000..be4a56e5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index d7edec9b..6e3e6a1c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 5d3a7a80..2beaec70 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/package-info.java new file mode 100644 index 00000000..72a03d65 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java index 3f264b02..b9aed821 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java @@ -12,14 +12,14 @@ import jakarta.validation.constraints.Size; *

Validation rules for displayName: *

* * @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 ) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/package-info.java new file mode 100644 index 00000000..c7ef261f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * Application DTOs used to keep HTTP request and response contracts separate + * from domain entities. + */ +package com.iflytek.skillhub.dto; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java index eef266bc..63f0c0aa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java @@ -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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java index 6c60cb10..77a97b45 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java @@ -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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index ebf0f43c..9e40abfd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java index 54622ed3..da494d0b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java @@ -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(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java index 5d41abe5..fe852f82 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java index 7be50e3b..2efade6b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java @@ -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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/package-info.java new file mode 100644 index 00000000..0ba38d23 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/package-info.java @@ -0,0 +1,5 @@ +/** + * Application-level exception translation and localized error payload support + * for the HTTP boundary. + */ +package com.iflytek.skillhub.exception; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 0338be30..366038ee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -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())) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java index e9780f46..9a950d60 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java @@ -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}. + * + *

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(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java index e9dc6422..cc5c0932 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index 7783f3c3..cda766ff 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -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 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 getHeaders(HttpServletRequest request) { - Map headers = new HashMap<>(); - Enumeration 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]"; } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/package-info.java new file mode 100644 index 00000000..4fec52ef --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java index eeef6e7e..2c536c74 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java index 903041f2..d0e13477 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/package-info.java new file mode 100644 index 00000000..911675c6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/package-info.java @@ -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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java index ab9714fc..abbd05dd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/package-info.java new file mode 100644 index 00000000..a9c95b62 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/package-info.java @@ -0,0 +1,5 @@ +/** + * Metrics helpers used to publish application and product telemetry from the + * web layer. + */ +package com.iflytek.skillhub.metrics; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/package-info.java new file mode 100644 index 00000000..682c3f9e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/package-info.java @@ -0,0 +1,7 @@ +/** + * Application-layer orchestration for the SkillHub backend. + * + *

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; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java index dee29900..f6ff6dff 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java index 816b6e3c..c37ade23 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ClientIpResolver.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java index 89f8be79..27dc4be8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimit.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimit.java index 1800ab83..5a187fd4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimit.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimit.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java index 8aa5be3d..da421b5d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java index 0d003ab3..7471dc2f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java @@ -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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java index 7117b9bc..fe33dbfe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/package-info.java new file mode 100644 index 00000000..48bcbc09 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/package-info.java @@ -0,0 +1,5 @@ +/** + * Rate-limiting annotations, interceptors, and implementations used to + * protect public APIs from abuse. + */ +package com.iflytek.skillhub.ratelimit; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java index c68bb7ed..fa5a0b16 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/package-info.java new file mode 100644 index 00000000..eba218ab --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/package-info.java @@ -0,0 +1,5 @@ +/** + * Application-specific query repositories that package read models tailored to + * web and administration use cases. + */ +package com.iflytek.skillhub.repository; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 44a7c626..81cebbde 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index 2611d3bf..8f5de8d2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java index 19997a4b..7b76875c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java index 9b7efe73..ce38aea7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/package-info.java new file mode 100644 index 00000000..5acb71e1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/package-info.java @@ -0,0 +1,5 @@ +/** + * Web security helpers that translate authorization failures, sanitize logs, + * and coordinate security-specific application behavior. + */ +package com.iflytek.skillhub.security; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index b811e9d7..f6dd7865 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java index fe807e4f..8670af6b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 7b649576..656cefa0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java deleted file mode 100644 index fe7133f7..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java +++ /dev/null @@ -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 listUsers(String keyword, String status, int page, int size) { - UserStatus userStatus = parseStatus(status); - Page users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size)); - List 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 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 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 withDefaultUserRole(Set roles) { - Set 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); - } - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuditRequestContext.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuditRequestContext.java new file mode 100644 index 00000000..23772575 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuditRequestContext.java @@ -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 + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 3e1f53c8..84324f18 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java index 3eb85795..caf62901 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java @@ -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 { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java index f11983dc..08a5e5a6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java @@ -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. + * + *

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 namespaceRoles, Set 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 listInbox(String userId, Map namespaceRoles, Set 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 listActivity(Set platformRoles, int page, int size) { if (!canReadActivity(platformRoles)) { return new PageResponse<>(List.of(), 0, page, size); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 00183e97..98407e53 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -25,6 +25,10 @@ import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; +/** + * Application service that assembles the current user's owned and starred + * skill lists with lifecycle context. + */ @Service public class MySkillAppService { private final SkillRepository skillRepository; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java index 0401a7c8..34eb60f7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java @@ -19,6 +19,10 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +/** + * Finds candidate users that can be invited into a namespace while excluding + * existing members and immutable namespaces. + */ @Service public class NamespaceMemberCandidateService { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java new file mode 100644 index 00000000..92c1340b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -0,0 +1,167 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; +import com.iflytek.skillhub.dto.NamespaceRequest; +import com.iflytek.skillhub.dto.NamespaceResponse; +import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; +import com.iflytek.skillhub.exception.ForbiddenException; +import com.iflytek.skillhub.exception.UnauthorizedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Command-facing namespace application service for portal endpoints. + */ +@Service +public class NamespacePortalCommandAppService { + + private final NamespaceService namespaceService; + private final NamespaceRepository namespaceRepository; + private final NamespaceGovernanceService namespaceGovernanceService; + private final NamespaceMemberService namespaceMemberService; + + public NamespacePortalCommandAppService(NamespaceService namespaceService, + NamespaceRepository namespaceRepository, + NamespaceGovernanceService namespaceGovernanceService, + NamespaceMemberService namespaceMemberService) { + this.namespaceService = namespaceService; + this.namespaceRepository = namespaceRepository; + this.namespaceGovernanceService = namespaceGovernanceService; + this.namespaceMemberService = namespaceMemberService; + } + + @Transactional + public NamespaceResponse createNamespace(NamespaceRequest request, 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 NamespaceResponse.from(namespace); + } + + @Transactional + public NamespaceResponse updateNamespace(String slug, NamespaceRequest request, String userId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + Namespace updated = namespaceService.updateNamespace( + namespace.getId(), + request.displayName(), + request.description(), + null, + userId + ); + return NamespaceResponse.from(updated); + } + + @Transactional + public NamespaceResponse freezeNamespace(String slug, + NamespaceLifecycleRequest request, + String userId, + AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.freezeNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + auditContext.clientIp(), + auditContext.userAgent() + ); + return NamespaceResponse.from(namespace); + } + + @Transactional + public NamespaceResponse unfreezeNamespace(String slug, String userId, AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.unfreezeNamespace( + slug, + userId, + null, + auditContext.clientIp(), + auditContext.userAgent() + ); + return NamespaceResponse.from(namespace); + } + + @Transactional + public NamespaceResponse archiveNamespace(String slug, + NamespaceLifecycleRequest request, + String userId, + AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.archiveNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + auditContext.clientIp(), + auditContext.userAgent() + ); + return NamespaceResponse.from(namespace); + } + + @Transactional + public NamespaceResponse restoreNamespace(String slug, String userId, AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.restoreNamespace( + slug, + userId, + null, + auditContext.clientIp(), + auditContext.userAgent() + ); + return NamespaceResponse.from(namespace); + } + + @Transactional + public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + NamespaceMember member = namespaceMemberService.addMember( + namespace.getId(), + memberUserId, + role, + operatorUserId + ); + return MemberResponse.from(member); + } + + @Transactional + public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId); + return new MessageResponse("Member removed successfully"); + } + + @Transactional + public MemberResponse updateMemberRole(String slug, + String userId, + UpdateMemberRoleRequest request, + String operatorUserId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + NamespaceMember member = namespaceMemberService.updateMemberRole( + namespace.getId(), + userId, + request.role(), + operatorUserId + ); + return MemberResponse.from(member); + } + + private boolean canCreateNamespace(PlatformPrincipal principal) { + return principal.platformRoles().contains("SKILL_ADMIN") + || principal.platformRoles().contains("SUPER_ADMIN"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java new file mode 100644 index 00000000..17dd9b99 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.MyNamespaceResponse; +import com.iflytek.skillhub.dto.NamespaceResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Query-facing namespace application service that keeps controller methods + * thin while preserving current response contracts. + */ +@Service +public class NamespacePortalQueryAppService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceService namespaceService; + private final NamespaceMemberService namespaceMemberService; + private final NamespaceAccessPolicy namespaceAccessPolicy; + + public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository, + NamespaceService namespaceService, + NamespaceMemberService namespaceMemberService, + NamespaceAccessPolicy namespaceAccessPolicy) { + this.namespaceRepository = namespaceRepository; + this.namespaceService = namespaceService; + this.namespaceMemberService = namespaceMemberService; + this.namespaceAccessPolicy = namespaceAccessPolicy; + } + + @Transactional(readOnly = true) + public PageResponse listNamespaces(Pageable pageable) { + Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); + return PageResponse.from(namespaces.map(NamespaceResponse::from)); + } + + @Transactional(readOnly = true) + public List listMyNamespaces(Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + return List.of(); + } + + return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .map(namespace -> MyNamespaceResponse.from( + namespace, + namespaceRoles.get(namespace.getId()), + namespaceAccessPolicy)) + .toList(); + } + + @Transactional(readOnly = true) + public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { + Namespace namespace = namespaceService.getNamespaceBySlugForRead( + slug, + userId, + userNamespaceRoles != null ? userNamespaceRoles : Map.of()); + return NamespaceResponse.from(namespace); + } + + @Transactional(readOnly = true) + public PageResponse listMembers(String slug, Pageable pageable, String userId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + namespaceService.assertMember(namespace.getId(), userId); + Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); + return PageResponse.from(members.map(MemberResponse::from)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java index f24606fa..5a90e820 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java @@ -13,6 +13,10 @@ import java.util.Map; import java.util.function.Function; import org.springframework.stereotype.Service; +/** + * Restores a platform session from a passive authenticator and persists the + * resulting principal into Spring Security's session context. + */ @Service public class SessionBootstrapService { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java new file mode 100644 index 00000000..db687186 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -0,0 +1,193 @@ +package com.iflytek.skillhub.service; + +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.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Orchestrates skill lifecycle mutations so controllers only handle transport + * concerns and envelope assembly. + */ +@Service +public class SkillLifecycleAppService { + + 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; + + public SkillLifecycleAppService(NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, + SkillGovernanceService skillGovernanceService, + ReviewService reviewService, + SkillPublishService skillPublishService, + AuditLogService auditLogService, + SkillSlugResolutionService skillSlugResolutionService) { + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillGovernanceService = skillGovernanceService; + this.reviewService = reviewService; + this.skillPublishService = skillPublishService; + this.auditLogService = auditLogService; + this.skillSlugResolutionService = skillSlugResolutionService; + } + + @Transactional + public SkillLifecycleMutationResponse archiveSkill(String namespace, + String slug, + AdminSkillActionRequest request, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + Skill archived = skillGovernanceService.archiveSkill( + skill.getId(), + userId, + normalizeRoles(userNamespaceRoles), + auditContext.clientIp(), + auditContext.userAgent(), + request != null ? request.reason() : null + ); + return new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name()); + } + + @Transactional + public SkillLifecycleMutationResponse unarchiveSkill(String namespace, + String slug, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + Skill restored = skillGovernanceService.unarchiveSkill( + skill.getId(), + userId, + normalizeRoles(userNamespaceRoles), + auditContext.clientIp(), + auditContext.userAgent() + ); + return new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name()); + } + + @Transactional + public SkillLifecycleMutationResponse deleteVersion(String namespace, + String slug, + String version, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillGovernanceService.deleteVersion( + skill, + skillVersion, + userId, + normalizeRoles(userNamespaceRoles), + auditContext.clientIp(), + auditContext.userAgent() + ); + return new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version); + } + + @Transactional + public SkillLifecycleMutationResponse withdrawReview(String namespace, + String slug, + String version, + String userId, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + SkillVersion withdrawnVersion = reviewService.withdrawReview(skillVersion.getId(), userId); + auditLogService.record( + userId, + "REVIEW_WITHDRAW", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "WITHDRAW_REVIEW", + withdrawnVersion.getStatus().name() + ); + } + + @Transactional + public SkillLifecycleMutationResponse rereleaseVersion(String namespace, + String slug, + String version, + SkillVersionRereleaseRequest request, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + String targetVersion = request.targetVersion().trim(); + SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion( + skill.getId(), + skillVersion.getVersion(), + targetVersion, + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "RERELEASE_SKILL_VERSION", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"sourceVersion\":\"" + version.replace("\"", "\\\"") + + "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}" + ); + return 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 skillSlugResolutionService.resolve( + namespace.getId(), + skillSlug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER + ); + } + + private SkillVersion findVersion(Long skillId, String version) { + return skillVersionRepository.findBySkillIdAndVersion(skillId, version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + } + + private Map normalizeRoles(Map userNamespaceRoles) { + return userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 13a01261..05d3cab3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -23,6 +23,10 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +/** + * Application service that adapts search queries to the search backend and + * enriches results with authoritative skill metadata and viewer permissions. + */ @Service public class SkillSearchAppService { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/package-info.java new file mode 100644 index 00000000..6dd561ce --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Application services that aggregate multiple domain services or repositories + * for controller-friendly use cases. + */ +package com.iflytek.skillhub.service; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java index 935ad253..ea40dfee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java @@ -10,6 +10,10 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; import java.time.Instant; +/** + * Periodic maintenance task that expires old idempotency records and marks stale processing + * entries as failed. + */ @Component public class IdempotencyCleanupTask { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/package-info.java new file mode 100644 index 00000000..dc5670e6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/package-info.java @@ -0,0 +1,4 @@ +/** + * Scheduled background tasks that maintain read models and operational data. + */ +package com.iflytek.skillhub.task; diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index c2d8bcaa..08ff728a 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -34,5 +34,5 @@ skillhub: logging: level: - com.iflytek.skillhub: DEBUG - org.springframework.security: DEBUG + com.iflytek.skillhub: INFO + org.springframework.security: WARN diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 465e2924..985c54b1 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -112,8 +112,8 @@ skillhub: allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.html,.css,.csv,.pdf,.toml,.xml,.ini,.cfg,.env,.js,.ts,.py,.sh,.rb,.go,.rs,.java,.kt,.lua,.sql,.r,.bat,.ps1,.zsh,.bash,.png,.jpg,.jpeg,.svg,.gif,.webp,.ico profile: moderation: - machine-review: ${SKILLHUB_PROFILE_MACHINE_REVIEW_ENABLED:true} # Enable machine review (e.g. sensitive word detection) - human-review: ${SKILLHUB_PROFILE_HUMAN_REVIEW_ENABLED:true} # Enable human review queue + machine-review: ${SKILLHUB_PROFILE_MACHINE_REVIEW_ENABLED:false} # Enable machine review (e.g. sensitive word detection) + human-review: ${SKILLHUB_PROFILE_HUMAN_REVIEW_ENABLED:false} # Enable human review queue device-auth: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} bootstrap: diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 9c117588..17309522 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -127,7 +127,7 @@ error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{ # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters -error.profile.displayName.pattern=Display name can only contain Chinese characters, English letters, numbers, underscores, and hyphens +error.profile.displayName.pattern=Display name can only contain Chinese characters, English letters, numbers, spaces, underscores, and hyphens error.profile.noChanges=At least one field must be provided response.profile.updated=Profile updated successfully response.profile.pendingReview=Profile changes submitted for review @@ -138,4 +138,3 @@ error.profileReview.commentRequired=Rejection reason is required error.profileReview.commentTooLong=Rejection reason must not exceed 500 characters error.profileReview.status.invalid=Invalid review status: {0} error.profileReview.userDisabled=Cannot apply changes — user account is disabled - diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index b29da03b..e3de9e82 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -127,7 +127,7 @@ error.skill.approve.nameConflict=无法通过审核:该命名空间下已存 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 -error.profile.displayName.pattern=昵称仅允许中文、英文、数字、下划线和连字符 +error.profile.displayName.pattern=昵称仅允许中文、英文、数字、空格、下划线和连字符 error.profile.noChanges=至少需要提供一个修改字段 response.profile.updated=资料已更新 response.profile.pendingReview=资料变更已提交审核 @@ -138,4 +138,3 @@ error.profileReview.commentRequired=拒绝原因不能为空 error.profileReview.commentTooLong=拒绝原因不能超过 500 个字符 error.profileReview.status.invalid=无效的审核状态:{0} error.profileReview.userDisabled=无法应用变更——用户账号已被禁用 - diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 5cc73856..76663e99 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -4,7 +4,9 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; @@ -54,6 +56,9 @@ class NamespacePortalControllerTest { @MockBean private NamespaceGovernanceService namespaceGovernanceService; + @MockBean + private NamespaceMemberService namespaceMemberService; + @MockBean private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository; @@ -116,6 +121,33 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data.status").value("ARCHIVED")); } + @Test + void updateNamespace_returnsUpdatedNamespace() throws Exception { + Namespace existing = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace updated = new Namespace("team-a", "Team A+", "owner-1"); + setField(updated, "id", 1L); + updated.setStatus(NamespaceStatus.ACTIVE); + updated.setType(NamespaceType.TEAM); + updated.setDescription("Updated description"); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing); + given(namespaceService.updateNamespace(1L, "Team A+", "Updated description", null, "owner-1")) + .willReturn(updated); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"slug":"team-a","displayName":"Team A+","description":"Updated description"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")) + .andExpect(jsonPath("$.data.displayName").value("Team A+")) + .andExpect(jsonPath("$.data.description").value("Updated description")); + } + @Test void listMembers_forNonMember_returns403() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); @@ -152,6 +184,64 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data[0].displayName").value("alice")); } + @Test + void addMember_returnsCreatedMember() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) + .willReturn(member); + + mockMvc.perform(post("/api/v1/namespaces/team-a/members") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"userId":"user-2","role":"ADMIN"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-2")) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + } + + @Test + void removeMember_returnsSuccessMessage() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/api/v1/namespaces/team-a/members/user-2") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Member removed successfully")); + } + + @Test + void updateMemberRole_returnsUpdatedMember() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + given(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) + .willReturn(member); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a/members/user-2/role") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"role":"OWNER"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-2")) + .andExpect(jsonPath("$.data.role").value("OWNER")); + } + @Test void createNamespace_requiresPlatformAdminRole() throws Exception { mockMvc.perform(post("/api/v1/namespaces") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java index 346072b8..5bed49fb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java @@ -3,8 +3,9 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.session.PlatformSessionService; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.ProfileChangeRequest; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository; import com.iflytek.skillhub.domain.user.ProfileChangeStatus; import com.iflytek.skillhub.domain.user.UserAccount; @@ -80,6 +81,8 @@ class UserProfileControllerTest { @MockBean private PlatformSessionService platformSessionService; + @MockBean + private AuditLogService auditLogService; // -- Helper -- private PlatformPrincipal testPrincipal() { @@ -132,6 +135,24 @@ class UserProfileControllerTest { .andExpect(jsonPath("$.data.status").value("APPLIED")); } + @Test + void updateProfile_displayNameWithSpaces_shouldReturn200() throws Exception { + var principal = testPrincipal(); + var user = new UserAccount("user-1", "OldName", "user@example.com", "https://example.com/avatar.png"); + + given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user)); + given(namespaceMemberRepository.findByUserId("user-1")).willReturn(List.of()); + given(userRoleBindingRepository.findByUserId("user-1")).willReturn(List.of()); + + mockMvc.perform(patch("/api/v1/user/profile") + .with(authentication(testAuth(principal))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"displayName\":\"New Name\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPLIED")); + } // ===== AC-E-001: Display name too short ===== @Test @@ -241,7 +262,6 @@ class UserProfileControllerTest { .andExpect(jsonPath("$.data.pendingChanges.status").value("PENDING")) .andExpect(jsonPath("$.data.pendingChanges.changes.displayName").value("NewName")); } - // ===== AC-S-003: XSS attempt ===== @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java new file mode 100644 index 00000000..bc281a81 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.search.SearchRebuildService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminSearchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SearchRebuildService searchRebuildService; + + @MockBean + private AuditLogService auditLogService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void rebuildAll_returnsOkForSuperAdmin() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/search/rebuild") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(searchRebuildService).rebuildAll(); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("REBUILD_SEARCH_INDEX"), + org.mockito.ArgumentMatchers.eq("SEARCH_INDEX"), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq("{\"scope\":\"ALL\"}") + ); + } + + @Test + void rebuildAll_returnsForbiddenForSkillAdmin() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/search/rebuild") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 822a06f5..2bb98567 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -26,10 +26,12 @@ import java.util.Set; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @SpringBootTest @@ -172,4 +174,73 @@ class UserManagementControllerTest { .andExpect(jsonPath("$.data.userId").value("user-123")) .andExpect(jsonPath("$.data.status").value("DISABLED")); } + + @Test + void approveUser_delegatesToActiveStatusMutation() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + when(adminUserAppService.updateUserStatus("user-123", "ACTIVE")) + .thenReturn(new AdminUserMutationResponse("user-123", null, "ACTIVE")); + + mockMvc.perform(post("/api/v1/admin/users/user-123/approve") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-123")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + + verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE"); + } + + @Test + void disableUser_delegatesToDisabledStatusMutation() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + when(adminUserAppService.updateUserStatus("user-123", "DISABLED")) + .thenReturn(new AdminUserMutationResponse("user-123", null, "DISABLED")); + + mockMvc.perform(post("/api/v1/admin/users/user-123/disable") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-123")) + .andExpect(jsonPath("$.data.status").value("DISABLED")); + + verify(adminUserAppService).updateUserStatus("user-123", "DISABLED"); + } + + @Test + void enableUser_delegatesToActiveStatusMutation() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + when(adminUserAppService.updateUserStatus("user-123", "ACTIVE")) + .thenReturn(new AdminUserMutationResponse("user-123", null, "ACTIVE")); + + mockMvc.perform(post("/api/v1/admin/users/user-123/enable") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-123")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + + verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index bd7ef931..acf5a24f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -229,6 +230,78 @@ class SkillLifecycleControllerTest { .andExpect(jsonPath("$.data.status").value("PUBLISHED")); } + @Test + void archiveSkill_acceptsAtPrefixedNamespaceSlug() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); + + mockMvc.perform(post("/api/web/skills/@global/demo-skill/archive") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.action").value("ARCHIVE")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + @Test + void rereleaseVersion_trimsTargetVersionBeforeDelegating() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner"); + setSkillVersionId(newVersion, 3L); + newVersion.setStatus(SkillVersionStatus.PUBLISHED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(sourceVersion, 2L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion)); + given(skillPublishService.rereleasePublishedVersion( + eq(1L), + eq("1.2.3"), + eq("1.2.4"), + eq("usr_1"), + anyMap())) + .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetVersion\":\" 1.2.4 \"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.versionId").value(3)) + .andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION")); + + verify(skillPublishService).rereleasePublishedVersion( + eq(1L), + eq("1.2.3"), + eq("1.2.4"), + eq("usr_1"), + anyMap()); + } + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { skill.setStatus(status); return skill; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index 80f761ea..6bf56337 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.filter; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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; @@ -32,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -51,7 +53,8 @@ class AuthContextFilterTest { userAccountRepository, apiResponseFactory, new ObjectMapper().registerModule(new JavaTimeModule()), - true + true, + new RouteSecurityPolicyRegistry() ); } @@ -67,6 +70,7 @@ class AuthContextFilterTest { user.setStatus(UserStatus.DISABLED); MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/auth/me"); MockHttpSession session = (MockHttpSession) request.getSession(true); session.setAttribute("platformPrincipal", principal); SecurityContextHolder.getContext().setAuthentication( @@ -95,6 +99,7 @@ class AuthContextFilterTest { NamespaceMember member = new NamespaceMember(9L, "user-2", NamespaceRole.ADMIN); MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/auth/me"); request.getSession(true).setAttribute("platformPrincipal", principal); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, List.of()) @@ -112,4 +117,20 @@ class AuthContextFilterTest { assertEquals(NamespaceRole.ADMIN, ((java.util.Map) request.getAttribute("userNsRoles")).get(9L)); verify(filterChain).doFilter(request, response); } + + @Test + void anonymousRequest_shouldPassThroughWithoutLoadingUserContext() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/assets/app.js"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + assertNull(request.getAttribute("userId")); + assertNull(request.getAttribute("userNsRoles")); + verify(filterChain).doFilter(same(request), same(response)); + verify(userAccountRepository, never()).findById(org.mockito.ArgumentMatchers.anyString()); + verify(namespaceMemberRepository, never()).findByUserId(org.mockito.ArgumentMatchers.anyString()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index 7d74d3ff..de158226 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; class RequestLoggingFilterTest { @Test - void doFilterInternal_truncatesLongRequestAndResponseBodiesInLogs(CapturedOutput output) + void doFilterInternal_truncatesLongRequestBodyAndOmitsResponseBody(CapturedOutput output) throws ServletException, IOException { RequestLoggingFilter filter = new RequestLoggingFilter(); String longBody = "x".repeat(5_000); @@ -39,10 +39,48 @@ class RequestLoggingFilterTest { filter.doFilter(request, response, filterChain); - assertThat(output).contains("Request Body: " + "x".repeat(512) + "... [truncated, original length=5000]"); - assertThat(output).contains("Response Body: " + "x".repeat(512) + "... [truncated, original length=5000]"); - assertThat(output).doesNotContain("Request Body: " + longBody); - assertThat(output).doesNotContain("Response Body: " + longBody); + // Request body should be truncated at 200 chars + assertThat(output).contains("Body: " + "x".repeat(200) + "...[truncated]"); + assertThat(output).doesNotContain("Body: " + longBody); + // Response body should not be logged at all + assertThat(output).doesNotContain("Response Body:"); + // Original response should still be intact assertThat(response.getContentAsString()).isEqualTo(longBody); } + + @Test + void doFilterInternal_skipsActuatorEndpoints(CapturedOutput output) + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/actuator/health"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> {}; + + filter.doFilter(request, response, filterChain); + + assertThat(output).doesNotContain("/actuator/health"); + } + + @Test + void doFilterInternal_logsCoreSummaryFields(CapturedOutput output) + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/skills"); + request.setRemoteAddr("127.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> {}; + + filter.doFilter(request, response, filterChain); + + assertThat(output).contains("GET /api/v1/skills"); + assertThat(output).contains("200"); + assertThat(output).contains("127.0.0.1"); + assertThat(output).contains("ms"); + // Should not contain full headers dump + assertThat(output).doesNotContain("Headers: {"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java new file mode 100644 index 00000000..80a76c15 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; +import com.iflytek.skillhub.dto.NamespaceRequest; +import com.iflytek.skillhub.exception.ForbiddenException; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +class NamespacePortalCommandAppServiceTest { + + private final NamespaceService namespaceService = mock(NamespaceService.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); + private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); + private final NamespacePortalCommandAppService service = new NamespacePortalCommandAppService( + namespaceService, + namespaceRepository, + namespaceGovernanceService, + namespaceMemberService + ); + + @Test + void createNamespace_requiresPlatformAdminRole() { + NamespaceRequest request = new NamespaceRequest("team-alpha", "Team Alpha", null); + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", "user-1", "user-1@example.com", "", "github", Set.of("USER") + ); + + assertThrows(ForbiddenException.class, () -> service.createNamespace(request, principal)); + } + + @Test + void freezeNamespace_mapsAuditContextAndReturnsResponse() { + Namespace namespace = namespace(7L, "team-alpha"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceGovernanceService.freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit")) + .thenReturn(namespace); + + var response = service.freezeNamespace( + "team-alpha", + new NamespaceLifecycleRequest("cleanup"), + "owner-1", + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + assertThat(response.id()).isEqualTo(7L); + assertThat(response.slug()).isEqualTo("team-alpha"); + assertThat(response.status()).isEqualTo(NamespaceStatus.FROZEN); + verify(namespaceGovernanceService).freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit"); + } + + private Namespace namespace(Long id, String slug) { + Namespace namespace = new Namespace(slug, "Team Alpha", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", id); + namespace.setType(NamespaceType.TEAM); + return namespace; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java new file mode 100644 index 00000000..61e6a458 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +class NamespacePortalQueryAppServiceTest { + + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final NamespaceService namespaceService = mock(NamespaceService.class); + private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); + private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); + private final NamespacePortalQueryAppService service = new NamespacePortalQueryAppService( + namespaceRepository, + namespaceService, + namespaceMemberService, + namespaceAccessPolicy + ); + + @Test + void listMyNamespaces_sortsBySlugAndProjectsRoleCapabilities() { + Namespace zeta = namespace(2L, "zeta"); + Namespace alpha = namespace(1L, "alpha"); + when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(zeta, alpha)); + when(namespaceAccessPolicy.isImmutable(alpha)).thenReturn(false); + when(namespaceAccessPolicy.canFreeze(alpha, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceAccessPolicy.canUnfreeze(alpha, NamespaceRole.OWNER)).thenReturn(false); + when(namespaceAccessPolicy.canArchive(alpha, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceAccessPolicy.canRestore(alpha, NamespaceRole.OWNER)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(zeta)).thenReturn(false); + when(namespaceAccessPolicy.canFreeze(zeta, NamespaceRole.ADMIN)).thenReturn(true); + when(namespaceAccessPolicy.canUnfreeze(zeta, NamespaceRole.ADMIN)).thenReturn(false); + when(namespaceAccessPolicy.canArchive(zeta, NamespaceRole.ADMIN)).thenReturn(true); + when(namespaceAccessPolicy.canRestore(zeta, NamespaceRole.ADMIN)).thenReturn(false); + + var response = service.listMyNamespaces(Map.of( + 2L, NamespaceRole.ADMIN, + 1L, NamespaceRole.OWNER + )); + + assertThat(response).hasSize(2); + assertThat(response.get(0).slug()).isEqualTo("alpha"); + assertThat(response.get(0).currentUserRole()).isEqualTo(NamespaceRole.OWNER); + assertThat(response.get(1).slug()).isEqualTo("zeta"); + assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN); + } + + private Namespace namespace(Long id, String slug) { + Namespace namespace = new Namespace(slug, slug, "owner-1"); + ReflectionTestUtils.setField(namespace, "id", id); + namespace.setStatus(NamespaceStatus.ACTIVE); + namespace.setType(NamespaceType.TEAM); + return namespace; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java new file mode 100644 index 00000000..53215011 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +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 org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; +import java.util.Optional; + +class SkillLifecycleAppServiceTest { + + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + private final SkillGovernanceService skillGovernanceService = mock(SkillGovernanceService.class); + private final ReviewService reviewService = mock(ReviewService.class); + private final SkillPublishService skillPublishService = mock(SkillPublishService.class); + private final AuditLogService auditLogService = mock(AuditLogService.class); + private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class); + private final SkillLifecycleAppService service = new SkillLifecycleAppService( + namespaceRepository, + skillVersionRepository, + skillGovernanceService, + reviewService, + skillPublishService, + auditLogService, + skillSlugResolutionService + ); + + @Test + void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 7L); + Skill skill = new Skill(7L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", 11L); + skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(7L, "demo-skill", "owner-1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .thenReturn(skill); + when(skillGovernanceService.archiveSkill(eq(11L), eq("owner-1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + .thenReturn(skill); + + var response = service.archiveSkill( + "global", + "demo-skill", + new AdminSkillActionRequest("cleanup"), + "owner-1", + Map.of(7L, NamespaceRole.OWNER), + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + assertThat(response.skillId()).isEqualTo(11L); + assertThat(response.action()).isEqualTo("ARCHIVE"); + assertThat(response.status()).isEqualTo("ARCHIVED"); + verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java new file mode 100644 index 00000000..85f528ac --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java @@ -0,0 +1,5 @@ +/** + * Authentication bootstrap helpers that restore session state from existing + * request context without forcing an explicit login step. + */ +package com.iflytek.skillhub.auth.bootstrap; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java index 74ea5215..aa36954f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java @@ -9,6 +9,9 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +/** + * Provides the shared Redis template used by authentication and other cross-cutting services. + */ @Configuration public class RedisTemplateConfig { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 664e9ba4..276826ec 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -5,17 +5,18 @@ import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; import com.iflytek.skillhub.auth.mock.MockAuthFilter; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; -import org.springframework.http.HttpMethod; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -29,6 +30,10 @@ import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWrite import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; +/** + * Central Spring Security configuration for browser sessions, API tokens, and + * public versus protected endpoints. + */ @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -54,6 +59,7 @@ public class SecurityConfig { private final AuthenticationEntryPoint apiAuthenticationEntryPoint; private final AccessDeniedHandler apiAccessDeniedHandler; private final ObjectProvider mockAuthFilterProvider; + private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, @@ -63,7 +69,8 @@ public class SecurityConfig { ApiTokenScopeFilter apiTokenScopeFilter, AuthenticationEntryPoint apiAuthenticationEntryPoint, AccessDeniedHandler apiAccessDeniedHandler, - ObjectProvider mockAuthFilterProvider) { + ObjectProvider mockAuthFilterProvider, + RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.customOAuth2UserService = customOAuth2UserService; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; @@ -73,8 +80,16 @@ public class SecurityConfig { this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint; this.apiAccessDeniedHandler = apiAccessDeniedHandler; this.mockAuthFilterProvider = mockAuthFilterProvider; + this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry; } + /** + * Builds the ordered security filter chain used by both browser and API + * clients. + * + *

The chain mixes session-based authentication, bearer token support, + * CSRF rules for browser traffic, and method-level authorization. + */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { var csrfHandler = new CsrfTokenRequestAttributeHandler(); @@ -82,15 +97,7 @@ public class SecurityConfig { RequestMatcher csrfIgnoreMatcher = request -> { String path = request.getRequestURI(); String authorization = request.getHeader("Authorization"); - if (authorization != null && authorization.startsWith("Bearer ")) { - return true; - } - if (path == null) { - return false; - } - return path.startsWith("/api/") - || path.equals("/api/v1/publish") - || path.startsWith("/api/v1/auth/device/"); + return routeSecurityPolicyRegistry.shouldIgnoreCsrf(path, authorization); }; http @@ -99,75 +106,10 @@ public class SecurityConfig { .csrfTokenRequestHandler(csrfHandler) .ignoringRequestMatchers(csrfIgnoreMatcher) ) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/v1/health", - "/api/v1/search", - "/api/v1/resolve/**", - "/api/v1/download/**", - "/api/v1/auth/providers", - "/api/v1/auth/methods", - "/api/v1/auth/me", - "/api/v1/auth/session/bootstrap", - "/api/v1/auth/direct/login", - "/api/v1/auth/local/**", - "/api/v1/auth/device/**", - "/api/v1/check", - "/actuator/health", - "/v3/api-docs/**", - "/swagger-ui/**", - "/.well-known/**", - "/api/v1/search", - "/api/v1/resolve/**", - "/api/v1/download/**" - ).permitAll() - .requestMatchers("/actuator/prometheus").hasAnyRole("SUPER_ADMIN", "AUDITOR") - .requestMatchers( - HttpMethod.GET, - "/api/v1/skills/*/star", - "/api/v1/skills/*/rating", - "/api/web/skills/*/star", - "/api/web/skills/*/rating" - ).authenticated() - .requestMatchers( - HttpMethod.GET, - "/api/v1/skills", - "/api/v1/skills/*/*", - "/api/v1/skills/*/*/versions", - "/api/v1/skills/*/*/versions/*", - "/api/v1/skills/*/*/download", - "/api/v1/skills/*/*/versions/*/download", - "/api/v1/skills/*/*/versions/*/files", - "/api/v1/skills/*/*/versions/*/file", - "/api/v1/skills/*/*/resolve", - "/api/v1/skills/*/*/tags", - "/api/v1/skills/*/*/tags/*/download", - "/api/v1/skills/*/*/tags/*/files", - "/api/v1/skills/*/*/tags/*/file", - "/api/web/skills", - "/api/web/skills/*/*", - "/api/web/skills/*/*/versions", - "/api/web/skills/*/*/versions/*", - "/api/web/skills/*/*/download", - "/api/web/skills/*/*/versions/*/download", - "/api/web/skills/*/*/versions/*/files", - "/api/web/skills/*/*/versions/*/file", - "/api/web/skills/*/*/resolve", - "/api/web/skills/*/*/tags", - "/api/web/skills/*/*/tags/*/download", - "/api/web/skills/*/*/tags/*/files", - "/api/web/skills/*/*/tags/*/file" - ).permitAll() - .requestMatchers( - HttpMethod.GET, - "/api/v1/namespaces", - "/api/v1/namespaces/*", - "/api/web/namespaces", - "/api/web/namespaces/*" - ).permitAll() - .requestMatchers("/api/v1/admin/**").authenticated() - .anyRequest().authenticated() - ) + .authorizeHttpRequests(auth -> { + configureRoutePolicies(auth); + auth.anyRequest().authenticated(); + }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) @@ -210,8 +152,22 @@ public class SecurityConfig { return http.build(); } + /** + * Provides the password encoder shared by local credentials and bootstrap + * flows. + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } + + private void configureRoutePolicies(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { + for (RouteSecurityPolicyRegistry.RouteAuthorizationPolicy policy : routeSecurityPolicyRegistry.authorizationPolicies()) { + switch (policy.accessLevel()) { + case PERMIT_ALL -> auth.requestMatchers(policy.toRequestMatcher()).permitAll(); + case AUTHENTICATED -> auth.requestMatchers(policy.toRequestMatcher()).authenticated(); + case ROLE_PROTECTED -> auth.requestMatchers(policy.toRequestMatcher()).hasAnyRole(policy.roles()); + } + } + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/package-info.java new file mode 100644 index 00000000..acde246f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/package-info.java @@ -0,0 +1,5 @@ +/** + * Spring Security and infrastructure configuration for authentication and + * authorization concerns. + */ +package com.iflytek.skillhub.auth.config; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index e9ac19a5..e838c9a8 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -11,6 +11,12 @@ import java.security.SecureRandom; import java.util.Base64; import java.util.concurrent.TimeUnit; +/** + * Implements the device authorization flow used by CLI-style clients. + * + *

State is stored in Redis so the browser authorization step and token + * polling step can rendezvous without holding server-side session state. + */ @Service public class DeviceAuthService { @@ -38,6 +44,10 @@ public class DeviceAuthService { this.verificationUri = verificationUri; } + /** + * Starts a new device flow and returns both the polling token and the + * user-facing verification code. + */ public DeviceCodeResponse generateDeviceCode() { String deviceCode = generateRandomDeviceCode(); String userCode = generateUserCode(); @@ -52,6 +62,9 @@ public class DeviceAuthService { return new DeviceCodeResponse(deviceCode, userCode, verificationUri, EXPIRES_IN_SECONDS, POLL_INTERVAL_SECONDS); } + /** + * Marks a user code as authorized by a concrete authenticated user. + */ public void authorizeDeviceCode(String userCode, String userId) { String deviceCode = (String) redisTemplate.opsForValue().get(USER_CODE_PREFIX + userCode); if (deviceCode == null) { @@ -79,6 +92,10 @@ public class DeviceAuthService { } } + /** + * Polls the device code and either returns a pending response or redeems it + * into an API token exactly once. + */ public DeviceTokenResponse pollToken(String deviceCode) { DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/package-info.java new file mode 100644 index 00000000..ca9aba63 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/package-info.java @@ -0,0 +1,4 @@ +/** + * Device authorization flow support used by CLI and headless clients. + */ +package com.iflytek.skillhub.auth.device; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java index 181b85a3..f0406c99 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java @@ -4,6 +4,9 @@ import com.iflytek.skillhub.auth.local.LocalAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import org.springframework.stereotype.Component; +/** + * Direct-auth provider that delegates username and password verification to the local auth flow. + */ @Component public class LocalDirectAuthProvider implements DirectAuthProvider { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java new file mode 100644 index 00000000..9905d8cb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java @@ -0,0 +1,5 @@ +/** + * Pluggable direct-login abstractions used by local or enterprise login + * experiences that bypass OAuth browser redirects. + */ +package com.iflytek.skillhub.auth.direct; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/package-info.java new file mode 100644 index 00000000..3c85a796 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/package-info.java @@ -0,0 +1,5 @@ +/** + * Authentication and authorization persistence entities such as tokens, + * identity bindings, roles, and grants. + */ +package com.iflytek.skillhub.auth.entity; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java index b2127c80..4190ae7e 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java @@ -2,6 +2,10 @@ package com.iflytek.skillhub.auth.exception; import org.springframework.http.HttpStatus; +/** + * Auth-layer exception that carries both an HTTP status and a localized message code for API + * rendering. + */ public class AuthFlowException extends RuntimeException { private final HttpStatus status; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/package-info.java new file mode 100644 index 00000000..37aa599f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/package-info.java @@ -0,0 +1,5 @@ +/** + * Exceptions that model expected authentication flow failures and map cleanly + * to HTTP responses. + */ +package com.iflytek.skillhub.auth.exception; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index d716d4f3..2a4fae8b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -16,6 +16,10 @@ import java.util.UUID; import java.util.Set; import java.util.stream.Collectors; +/** + * Resolves external OAuth identities to platform users, creating or updating + * bindings and user records as needed. + */ @Service public class IdentityBindingService { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/package-info.java new file mode 100644 index 00000000..f9e7b72f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/package-info.java @@ -0,0 +1,5 @@ +/** + * Services that manage the relationship between platform users and external + * identity-provider subjects. + */ +package com.iflytek.skillhub.auth.identity; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 4fd3fd99..9d378e25 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -21,6 +21,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Handles username-and-password registration and login for first-party local + * accounts. + */ @Service public class LocalAuthService { @@ -57,6 +61,10 @@ public class LocalAuthService { this.clock = clock; } + /** + * Registers a new local user, creates the credential record, and ensures + * the user is enrolled in the global namespace. + */ @Transactional public PlatformPrincipal register(String username, String password, String email) { String normalizedUsername = normalizeUsername(username); @@ -96,6 +104,10 @@ public class LocalAuthService { return buildPrincipal(user); } + /** + * Authenticates a local account and returns the principal snapshot used to + * establish a web session. + */ @Transactional public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); @@ -124,6 +136,9 @@ public class LocalAuthService { return buildPrincipal(user); } + /** + * Changes the stored password for an already authenticated local account. + */ @Transactional public void changePassword(String userId, String currentPassword, String newPassword) { LocalCredential credential = credentialRepository.findByUserId(userId) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java index ffb65668..8346b9c2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -4,6 +4,9 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +/** + * JPA repository for username-password credentials linked to platform user accounts. + */ @Repository public interface LocalCredentialRepository extends JpaRepository { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java index 1afcb86f..aca8baf3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java @@ -4,6 +4,9 @@ import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Component; +/** + * Validates local-account passwords against the platform's length and character diversity rules. + */ @Component public class PasswordPolicyValidator { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java new file mode 100644 index 00000000..7aa1200b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java @@ -0,0 +1,5 @@ +/** + * Username-and-password authentication support, including registration, + * password changes, and local credential validation. + */ +package com.iflytek.skillhub.auth.local; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java index 8c98fb3c..8638cad5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java @@ -4,6 +4,9 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +/** + * JPA repository for pending account-merge requests between two platform identities. + */ @Repository public interface AccountMergeRequestRepository extends JpaRepository { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index 92368699..20fc4080 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -32,6 +32,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Coordinates account merge requests and consolidates credentials, bindings, + * roles, memberships, and tokens into a single primary user. + */ @Service public class AccountMergeService { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/package-info.java new file mode 100644 index 00000000..61aea59e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/package-info.java @@ -0,0 +1,5 @@ +/** + * Account-merge workflows for consolidating multiple authentication identities + * into a single platform user. + */ +package com.iflytek.skillhub.auth.merge; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java index a0ab03ae..8d42ed5a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java @@ -21,6 +21,9 @@ import java.io.IOException; import java.util.Set; import java.util.stream.Collectors; +/** + * Local-development filter that can establish a session for a requested mock user header. + */ @Component @Profile("local") @ConditionalOnProperty(name = "skillhub.auth.mock.enabled", havingValue = "true") diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/package-info.java new file mode 100644 index 00000000..a2c9e502 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/package-info.java @@ -0,0 +1,5 @@ +/** + * Development-only authentication helpers that simulate authenticated callers + * in local environments. + */ +package com.iflytek.skillhub.auth.mock; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountDisabledException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountDisabledException.java index a2dd1b19..962165ec 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountDisabledException.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountDisabledException.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.oauth; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +/** + * OAuth authentication exception raised when the mapped platform account is disabled. + */ public class AccountDisabledException extends OAuth2AuthenticationException { public AccountDisabledException() { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountPendingException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountPendingException.java index a5cbaac6..9a7ccef9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountPendingException.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/AccountPendingException.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.oauth; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +/** + * OAuth authentication exception raised when the mapped platform account is pending approval. + */ public class AccountPendingException extends OAuth2AuthenticationException { public AccountPendingException() { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java index 774d4bef..2b013f47 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java @@ -22,6 +22,10 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +/** + * Spring Security OAuth user-service bridge that extracts provider claims, + * evaluates access policy, and maps the result to a {@link PlatformPrincipal}. + */ @Service public class CustomOAuth2UserService implements OAuth2UserService { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java index f7e807b9..51b802da 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java @@ -12,6 +12,10 @@ import java.util.List; import org.springframework.stereotype.Component; import java.util.Map; +/** + * Provider-specific claims extractor that enriches GitHub OAuth users with their primary verified + * email when necessary. + */ @Component public class GitHubClaimsExtractor implements OAuthClaimsExtractor { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 4f18a23a..4813e13c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -12,6 +12,10 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +/** + * Failure handler for OAuth logins that normalizes policy and account-state + * failures into predictable user-facing redirects. + */ @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index d0a1d1b2..3df9a237 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -13,6 +13,10 @@ import org.springframework.stereotype.Component; import java.io.IOException; +/** + * Login success handler that copies the resolved platform principal into the + * HTTP session and then redirects to the stored return target. + */ @Component public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java index 679d1a39..78dc9b80 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java @@ -2,6 +2,10 @@ package com.iflytek.skillhub.auth.oauth; import java.util.Map; +/** + * Normalized identity claims extracted from an OAuth provider before local account decisions are + * made. + */ public record OAuthClaims( String provider, String subject, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java index 826b3c9b..64b75ec8 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.oauth; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.user.OAuth2User; +/** + * Strategy interface for converting provider-specific OAuth user payloads into normalized claims. + */ public interface OAuthClaimsExtractor { String getProvider(); OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java index 6d1fd77f..feb60816 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.auth.oauth; +/** + * Utility methods and constants for safely handling post-login redirect targets in OAuth flows. + */ public final class OAuthLoginRedirectSupport { public static final String SESSION_RETURN_TO_ATTRIBUTE = "skillhub.oauth.returnTo"; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index 6b42f593..0e67b325 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -6,6 +6,10 @@ import org.springframework.security.oauth2.client.web.DefaultOAuth2Authorization import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.stereotype.Component; +/** + * OAuth2 authorization request resolver that preserves a sanitized post-login redirect target in + * the HTTP session. + */ @Component public class SkillHubOAuth2AuthorizationRequestResolver implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/package-info.java new file mode 100644 index 00000000..00ac05bc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/package-info.java @@ -0,0 +1,5 @@ +/** + * OAuth login adapters, claims extraction, and redirect coordination for + * browser-based third-party authentication. + */ +package com.iflytek.skillhub.auth.oauth; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java index 1aea1213..2c33fcd3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.auth.policy; +/** + * Possible outcomes when evaluating whether an externally authenticated user may access the + * platform. + */ public enum AccessDecision { ALLOW, DENY, PENDING_APPROVAL } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java index f2049308..8ac38ea6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.auth.policy; import com.iflytek.skillhub.auth.oauth.OAuthClaims; +/** + * Policy contract for deciding whether externally authenticated users may enter the platform. + */ public interface AccessPolicy { AccessDecision evaluate(OAuthClaims claims); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java index 79602966..b647c92b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java @@ -6,6 +6,9 @@ import org.springframework.context.annotation.Configuration; import java.util.List; import java.util.Set; +/** + * Builds the active external-access policy from configuration properties. + */ @Configuration @ConfigurationProperties(prefix = "skillhub.access-policy") public class AccessPolicyFactory { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java index fe9d442f..d688f2a2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.policy; import com.iflytek.skillhub.auth.oauth.OAuthClaims; import java.util.Set; +/** + * Access policy that allows login only when the OAuth email belongs to an approved domain. + */ public class EmailDomainAccessPolicy implements AccessPolicy { private final Set allowedDomains; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java index c0ef33ba..9febc325 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.auth.policy; import com.iflytek.skillhub.auth.oauth.OAuthClaims; +/** + * Access policy that accepts all OAuth-authenticated users. + */ public class OpenAccessPolicy implements AccessPolicy { @Override public AccessDecision evaluate(OAuthClaims claims) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java index 2457f123..d44ec348 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.policy; import com.iflytek.skillhub.auth.oauth.OAuthClaims; import java.util.Set; +/** + * Access policy that limits login to explicitly allowed OAuth providers. + */ public class ProviderAllowlistAccessPolicy implements AccessPolicy { private final Set allowedProviders; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java new file mode 100644 index 00000000..297de089 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -0,0 +1,205 @@ +package com.iflytek.skillhub.auth.policy; + +import java.util.List; +import java.util.Set; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; + +/** + * Authoritative route-policy catalog shared by web security configuration, + * API-token scope checks, and request-context projection. + */ +@Component +public class RouteSecurityPolicyRegistry { + + private static final List AUTHORIZATION_POLICIES = List.of( + RouteAuthorizationPolicy.permitAll(null, "/api/v1/health"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/search"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/resolve/**"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/download/**"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/providers"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/methods"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/me"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/session/bootstrap"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"), + RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"), + RouteAuthorizationPolicy.permitAll(null, "/actuator/health"), + RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"), + RouteAuthorizationPolicy.permitAll(null, "/swagger-ui/**"), + RouteAuthorizationPolicy.permitAll(null, "/.well-known/**"), + RouteAuthorizationPolicy.roles(null, "/actuator/prometheus", "SUPER_ADMIN", "AUDITOR"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/star"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/rating"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/star"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/rating"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/download"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*/download"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*/files"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*/file"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/resolve"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/download"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/files"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/download"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*/download"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*/files"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*/file"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/resolve"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/download"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"), + RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**") + ); + + private static final List API_TOKEN_POLICIES = List.of( + ApiTokenPolicy.allow(null, "/api/v1/health"), + ApiTokenPolicy.allow(null, "/api/v1/auth/providers"), + ApiTokenPolicy.allow(null, "/api/v1/auth/me"), + ApiTokenPolicy.allow(null, "/api/v1/auth/device/**"), + ApiTokenPolicy.allow(null, "/api/v1/check"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/whoami"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/search"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"), + ApiTokenPolicy.allow(null, "/.well-known/**"), + ApiTokenPolicy.allow(null, "/actuator/health"), + ApiTokenPolicy.allow(null, "/v3/api-docs/**"), + ApiTokenPolicy.allow(null, "/swagger-ui/**"), + ApiTokenPolicy.require(null, "/api/v1/tokens", "token:manage"), + ApiTokenPolicy.require(null, "/api/v1/tokens/**", "token:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish") + ); + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + public List authorizationPolicies() { + return AUTHORIZATION_POLICIES; + } + + public ApiTokenAuthorizationDecision authorizeApiToken(String method, String path, Set tokenScopes) { + if (!isApiPath(path)) { + return ApiTokenAuthorizationDecision.allow(); + } + + for (ApiTokenPolicy policy : API_TOKEN_POLICIES) { + if (!policy.matches(method, path, pathMatcher)) { + continue; + } + if (policy.requiredScope() == null || tokenScopes.contains(policy.requiredScope())) { + return ApiTokenAuthorizationDecision.allow(); + } + return ApiTokenAuthorizationDecision.missingScope(policy.requiredScope()); + } + + return ApiTokenAuthorizationDecision.unsupported(path); + } + + public boolean shouldIgnoreCsrf(String path, String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return true; + } + if (path == null) { + return false; + } + return path.startsWith("/api/") + || path.equals("/api/v1/publish") + || path.startsWith("/api/v1/auth/device/"); + } + + public boolean shouldProjectRequestContext(String path) { + return path != null && (path.startsWith("/api/v1/") + || path.startsWith("/api/web/") + || path.startsWith("/api/")); + } + + private boolean isApiPath(String path) { + return shouldProjectRequestContext(path); + } + + public record ApiTokenAuthorizationDecision(boolean allowed, String requiredScope, String message) { + public static ApiTokenAuthorizationDecision allow() { + return new ApiTokenAuthorizationDecision(true, null, null); + } + + public static ApiTokenAuthorizationDecision missingScope(String requiredScope) { + return new ApiTokenAuthorizationDecision(false, requiredScope, "Missing API token scope: " + requiredScope); + } + + public static ApiTokenAuthorizationDecision unsupported(String path) { + return new ApiTokenAuthorizationDecision(false, null, "API token cannot access endpoint: " + path); + } + } + + public enum AccessLevel { + PERMIT_ALL, + AUTHENTICATED, + ROLE_PROTECTED + } + + public record RouteAuthorizationPolicy(HttpMethod method, String pattern, AccessLevel accessLevel, String[] roles) { + public static RouteAuthorizationPolicy permitAll(HttpMethod method, String pattern) { + return new RouteAuthorizationPolicy(method, pattern, AccessLevel.PERMIT_ALL, new String[0]); + } + + public static RouteAuthorizationPolicy authenticated(HttpMethod method, String pattern) { + return new RouteAuthorizationPolicy(method, pattern, AccessLevel.AUTHENTICATED, new String[0]); + } + + public static RouteAuthorizationPolicy roles(HttpMethod method, String pattern, String... roles) { + return new RouteAuthorizationPolicy(method, pattern, AccessLevel.ROLE_PROTECTED, roles); + } + + public RequestMatcher toRequestMatcher() { + return method == null + ? new AntPathRequestMatcher(pattern) + : new AntPathRequestMatcher(pattern, method.name()); + } + } + + private record ApiTokenPolicy(HttpMethod method, String pattern, String requiredScope) { + static ApiTokenPolicy allow(HttpMethod method, String pattern) { + return new ApiTokenPolicy(method, pattern, null); + } + + static ApiTokenPolicy require(HttpMethod method, String pattern, String requiredScope) { + return new ApiTokenPolicy(method, pattern, requiredScope); + } + + boolean matches(String requestMethod, String requestPath, AntPathMatcher matcher) { + if (method != null && (requestMethod == null || !method.name().equalsIgnoreCase(requestMethod))) { + return false; + } + return matcher.match(pattern, requestPath); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java index daf6acaf..0b9546f5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.policy; import com.iflytek.skillhub.auth.oauth.OAuthClaims; import java.util.Set; +/** + * Access policy that only permits a configured set of provider-subject pairs. + */ public class SubjectWhitelistAccessPolicy implements AccessPolicy { private final Set whitelistedSubjects; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/package-info.java new file mode 100644 index 00000000..7a4d3a21 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/package-info.java @@ -0,0 +1,5 @@ +/** + * Access policies that decide whether an external identity is allowed to sign + * in to the platform. + */ +package com.iflytek.skillhub.auth.policy; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java index 0dfdf196..67f13102 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.auth.rbac; import java.io.Serializable; import java.util.Set; +/** + * Serializable authenticated principal shared across session, OAuth, and API-token flows. + */ public record PlatformPrincipal( String userId, String displayName, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java index 2ccfedee..eb34da07 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java @@ -4,6 +4,9 @@ import java.util.Collection; import java.util.Set; import java.util.TreeSet; +/** + * Utility methods for normalizing platform role sets and ensuring a baseline user role. + */ public final class PlatformRoleDefaults { public static final String DEFAULT_USER_ROLE = "USER"; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java index 8c94e2ee..9aa4a185 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java @@ -11,6 +11,10 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +/** + * Resolves platform roles and permissions for a user from persisted RBAC + * bindings. + */ @Service public class RbacService { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/package-info.java new file mode 100644 index 00000000..a7b73f79 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/package-info.java @@ -0,0 +1,5 @@ +/** + * Role-based access control primitives and services for platform-wide + * authorization checks. + */ +package com.iflytek.skillhub.auth.rbac; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index 765c6946..b24c840c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -8,6 +8,9 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * JPA repository for personal API tokens and token listings scoped to one user. + */ @Repository public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java index 12f48f8f..22499604 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java @@ -5,6 +5,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; +/** + * JPA repository for links between platform users and external identity-provider subjects. + */ @Repository public interface IdentityBindingRepository extends JpaRepository { Optional findByProviderCodeAndSubject(String providerCode, String subject); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java index 2fcc3a46..e858f067 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java @@ -5,6 +5,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; +/** + * JPA repository for platform roles addressed by their stable code. + */ @Repository public interface RoleRepository extends JpaRepository { Optional findByCode(String code); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java index 84e5bd37..162ed4fd 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -7,6 +7,9 @@ import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +/** + * JPA repository for direct user-to-role assignments in the RBAC model. + */ @Repository public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/package-info.java new file mode 100644 index 00000000..0225b70f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/package-info.java @@ -0,0 +1,4 @@ +/** + * Repository ports for authentication persistence concerns. + */ +package com.iflytek.skillhub.auth.repository; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java index 01332a6d..3908ab71 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -10,13 +10,25 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Service; +/** + * Synchronizes {@link PlatformPrincipal} snapshots with Spring Security's + * session-backed authentication context. + */ @Service public class PlatformSessionService { + /** + * Establishes a new authenticated session and rotates the session id to + * reduce fixation risk. + */ public void establishSession(PlatformPrincipal principal, HttpServletRequest request) { establishSession(principal, request, true); } + /** + * Establishes a session for the supplied principal and optionally rotates + * the underlying servlet session id. + */ public void establishSession(PlatformPrincipal principal, HttpServletRequest request, boolean rotateSessionId) { @@ -27,6 +39,10 @@ public class PlatformSessionService { persist(principal, authentication, request, rotateSessionId); } + /** + * Rebinds an updated principal to an already authenticated request without + * discarding the existing authentication object. + */ public void attachToAuthenticatedSession(PlatformPrincipal principal, Authentication authentication, HttpServletRequest request) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/package-info.java new file mode 100644 index 00000000..6afa5443 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/package-info.java @@ -0,0 +1,5 @@ +/** + * Services that synchronize authenticated principals with the Spring Security + * session model. + */ +package com.iflytek.skillhub.auth.session; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 3b227842..82c0f2c1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -23,6 +23,10 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +/** + * Authenticates bearer tokens and projects them into a Spring Security + * principal with both roles and token scopes. + */ @Component public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index aea4dfe0..8f69d5aa 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -17,6 +17,9 @@ import java.io.IOException; import java.util.Set; import java.util.stream.Collectors; +/** + * Enforces fine-grained API token scopes after token authentication has established the principal. + */ @Component public class ApiTokenScopeFilter extends OncePerRequestFilter { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java index 69c569c2..29eb6993 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java @@ -2,60 +2,30 @@ package com.iflytek.skillhub.auth.token; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import org.springframework.stereotype.Service; -import org.springframework.util.AntPathMatcher; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +/** + * Parses token scopes and evaluates whether a token may access a given HTTP + * method and path combination. + */ @Service public class ApiTokenScopeService { private static final TypeReference> STRING_LIST = new TypeReference<>() { }; - private static final List UNSCOPED_ALLOWED_RULES = List.of( - ScopeRule.allow(null, "/api/v1/health"), - ScopeRule.allow(null, "/api/v1/auth/providers"), - ScopeRule.allow(null, "/api/v1/auth/me"), - ScopeRule.allow(null, "/api/v1/auth/device/**"), - ScopeRule.allow(null, "/api/v1/check"), - ScopeRule.allow("GET", "/api/v1/whoami"), - ScopeRule.allow("GET", "/api/v1/search"), - ScopeRule.allow("GET", "/api/v1/skills"), - ScopeRule.allow("GET", "/api/v1/skills/**"), - ScopeRule.allow("GET", "/api/web/skills"), - ScopeRule.allow("GET", "/api/web/skills/**"), - ScopeRule.allow("GET", "/api/v1/namespaces"), - ScopeRule.allow("GET", "/api/v1/namespaces/*"), - ScopeRule.allow("GET", "/api/web/namespaces"), - ScopeRule.allow("GET", "/api/web/namespaces/*"), - ScopeRule.allow("GET", "/api/v1/search"), - ScopeRule.allow("GET", "/api/v1/resolve/**"), - ScopeRule.allow("GET", "/api/v1/whoami"), - ScopeRule.allow("GET", "/api/v1/download"), - ScopeRule.allow(null, "/.well-known/**"), - ScopeRule.allow(null, "/actuator/health"), - ScopeRule.allow(null, "/v3/api-docs/**"), - ScopeRule.allow(null, "/swagger-ui/**") - ); - - private static final List REQUIRED_SCOPE_RULES = List.of( - ScopeRule.require(null, "/api/v1/tokens", "token:manage"), - ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"), - ScopeRule.require("POST", "/api/v1/skills", "skill:publish"), - ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"), - ScopeRule.require("POST", "/api/web/skills/*/publish", "skill:publish"), - ScopeRule.require("POST", "/api/v1/publish", "skill:publish"), - ScopeRule.require("POST", "/api/v1/publish", "skill:publish") - ); - private final ObjectMapper objectMapper; - private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; - public ApiTokenScopeService(ObjectMapper objectMapper) { + public ApiTokenScopeService(ObjectMapper objectMapper, + RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.objectMapper = objectMapper; + this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry; } public Set parseScopes(String scopeJson) { @@ -81,32 +51,17 @@ public class ApiTokenScopeService { } public AuthorizationDecision authorize(String method, String path, Set tokenScopes) { - if (!isApiPath(path)) { + RouteSecurityPolicyRegistry.ApiTokenAuthorizationDecision decision = + routeSecurityPolicyRegistry.authorizeApiToken(method, path, tokenScopes); + if (decision.allowed()) { return AuthorizationDecision.allow(); } - - for (ScopeRule rule : UNSCOPED_ALLOWED_RULES) { - if (rule.matches(method, path, pathMatcher)) { - return AuthorizationDecision.allow(); - } + if (decision.requiredScope() != null) { + return AuthorizationDecision.missingScope(decision.requiredScope()); } - - for (ScopeRule rule : REQUIRED_SCOPE_RULES) { - if (rule.matches(method, path, pathMatcher)) { - if (tokenScopes.contains(rule.requiredScope())) { - return AuthorizationDecision.allow(); - } - return AuthorizationDecision.missingScope(rule.requiredScope()); - } - } - return AuthorizationDecision.unsupported(path); } - private boolean isApiPath(String path) { - return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/")); - } - public record AuthorizationDecision(boolean allowed, String requiredScope, String message) { public static AuthorizationDecision allow() { return new AuthorizationDecision(true, null, null); @@ -120,21 +75,4 @@ public class ApiTokenScopeService { return new AuthorizationDecision(false, null, "API token cannot access endpoint: " + path); } } - - private record ScopeRule(String method, String pattern, String requiredScope) { - static ScopeRule allow(String method, String pattern) { - return new ScopeRule(method, pattern, null); - } - - static ScopeRule require(String method, String pattern, String requiredScope) { - return new ScopeRule(method, pattern, requiredScope); - } - - boolean matches(String requestMethod, String requestPath, AntPathMatcher matcher) { - if (method != null && !method.equalsIgnoreCase(requestMethod)) { - return false; - } - return matcher.match(pattern, requestPath); - } - } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index 6ffcaa23..d750a860 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -25,6 +25,9 @@ import java.util.HexFormat; import java.util.List; import java.util.Optional; +/** + * Issues, rotates, validates, and revokes API tokens for non-browser clients. + */ @Service public class ApiTokenService { @@ -42,11 +45,18 @@ public class ApiTokenService { public record TokenCreateResult(String rawToken, ApiToken entity) {} + /** + * Creates a token without an explicit expiration timestamp. + */ @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson) { return createToken(userId, name, scopeJson, null); } + /** + * Creates a new token and returns the raw secret exactly once to the + * caller. + */ @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson, String expiresAt) { String normalizedName = normalizeName(name); @@ -78,6 +88,10 @@ public class ApiTokenService { return rotateToken(userId, name, scopeJson, null); } + /** + * Rotates a token name by revoking the previous active token before issuing + * a replacement. + */ @Transactional public TokenCreateResult rotateToken(String userId, String name, String scopeJson, String expiresAt) { String normalizedName = normalizeName(name); @@ -89,11 +103,18 @@ public class ApiTokenService { return createToken(userId, name, scopeJson, expiresAt); } + /** + * Validates a raw bearer token against its hash and lifecycle timestamps. + */ public Optional validateToken(String rawToken) { String hash = sha256(rawToken); return tokenRepo.findByTokenHash(hash).filter(token -> token.isValid(currentTime())); } + /** + * Revokes a token owned by the current user. Missing or foreign tokens are + * ignored to keep revocation idempotent. + */ @Transactional public void revokeToken(Long tokenId, String userId) { tokenRepo.findById(tokenId) @@ -104,6 +125,9 @@ public class ApiTokenService { }); } + /** + * Updates the expiration timestamp of an active token owned by the caller. + */ @Transactional public ApiToken updateExpiration(Long tokenId, String userId, String expiresAt) { ApiToken token = tokenRepo.findById(tokenId) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/package-info.java new file mode 100644 index 00000000..4fbcc815 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/package-info.java @@ -0,0 +1,5 @@ +/** + * API token issuance, authentication, and scope enforcement for non-browser + * clients. + */ +package com.iflytek.skillhub.auth.token; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java new file mode 100644 index 00000000..e97961f0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.auth.policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RouteSecurityPolicyRegistryTest { + + private final RouteSecurityPolicyRegistry registry = new RouteSecurityPolicyRegistry(); + + @Test + void authorizeApiToken_requiresPublishScopeForPublishEndpoints() { + var denied = registry.authorizeApiToken("POST", "/api/web/skills/global/publish", Set.of("skill:read")); + var allowed = registry.authorizeApiToken("POST", "/api/web/skills/global/publish", Set.of("skill:publish")); + + assertFalse(denied.allowed()); + assertEquals("skill:publish", denied.requiredScope()); + assertTrue(allowed.allowed()); + } + + @Test + void shouldIgnoreCsrf_forBearerAndApiPaths() { + assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); + assertTrue(registry.shouldIgnoreCsrf("/not-api", "Bearer token")); + assertFalse(registry.shouldIgnoreCsrf("/ui/settings", null)); + } + + @Test + void shouldProjectRequestContext_onlyForApiRoutes() { + assertTrue(registry.shouldProjectRequestContext("/api/web/namespaces/team-a")); + assertFalse(registry.shouldProjectRequestContext("/assets/index.css")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index f40e542c..e82f7a1a 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; @@ -31,7 +32,8 @@ class ApiTokenAuthenticationFilterTest { private final ApiTokenService apiTokenService = mock(ApiTokenService.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); private final UserRoleBindingRepository roleBindingRepository = mock(UserRoleBindingRepository.class); - private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper()); + private final ApiTokenScopeService scopeService = + new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry()); private final ApiTokenAuthenticationFilter filter = new ApiTokenAuthenticationFilter( apiTokenService, userAccountRepository, diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index 735fddfc..9156eba8 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; @@ -27,7 +28,8 @@ import static org.mockito.Mockito.verify; class ApiTokenScopeFilterTest { - private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper()); + private final ApiTokenScopeService scopeService = + new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry()); @AfterEach void clearSecurityContext() { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java index 002ec8a7..cc877bf3 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import org.junit.jupiter.api.Test; import java.util.Set; @@ -11,7 +12,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ApiTokenScopeServiceTest { - private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper()); + private final ApiTokenScopeService scopeService = + new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry()); @Test void parseScopesShouldNormalizeJsonArray() { @@ -74,4 +76,26 @@ class ApiTokenScopeServiceTest { assertFalse(decision.allowed()); assertEquals("API token cannot access endpoint: /api/v1/me/skills", decision.message()); } + + @Test + void authorizeShouldAllowPublicNamespaceReadWithoutScope() { + ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize( + "GET", + "/api/v1/namespaces/team-a", + Set.of() + ); + + assertTrue(decision.allowed()); + } + + @Test + void authorizeShouldPermitAuthMeWithoutScope() { + ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize( + "GET", + "/api/v1/auth/me", + Set.of() + ); + + assertTrue(decision.allowed()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java index b2f74ef0..a94a1d27 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java @@ -4,6 +4,9 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +/** + * Read-side service for paginating audit log entries with simple filters. + */ @Service public class AuditLogQueryService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java index bd967a41..b3b80541 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.domain.audit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +/** + * Domain repository contract for audit-log persistence and filtered pagination. + */ public interface AuditLogRepository { AuditLog save(AuditLog auditLog); Page search(String actorUserId, String action, Pageable pageable); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java index e477abe7..be9ef5f9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java @@ -6,6 +6,9 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; import java.time.Instant; +/** + * Records audit log entries for administrative and security-relevant actions. + */ @Service public class AuditLogService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/package-info.java new file mode 100644 index 00000000..dd3bfe73 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/package-info.java @@ -0,0 +1,5 @@ +/** + * Audit-domain abstractions for recording and querying security-sensitive or + * governance-relevant actions. + */ +package com.iflytek.skillhub.domain.audit; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/package-info.java new file mode 100644 index 00000000..3107b825 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/package-info.java @@ -0,0 +1,4 @@ +/** + * Cross-domain integration events published by the core business layer. + */ +package com.iflytek.skillhub.domain.event; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java index 519c84a9..b6a7b8f6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java @@ -8,6 +8,9 @@ import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Persists and manages governance notifications delivered to end users. + */ @Service public class GovernanceNotificationService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java index 85563adf..a6f159a8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.domain.governance; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for user-facing governance notifications. + */ public interface UserNotificationRepository { UserNotification save(UserNotification notification); Optional findById(Long id); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/package-info.java new file mode 100644 index 00000000..60f14992 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/package-info.java @@ -0,0 +1,5 @@ +/** + * Governance notification models and ports used to inform users about review + * and moderation outcomes. + */ +package com.iflytek.skillhub.domain.governance; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java index f4c298a3..4fee46c5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.domain.idempotency; import java.time.Instant; import java.util.Optional; +/** + * Domain repository contract for tracking request idempotency state and cleanup operations. + */ public interface IdempotencyRecordRepository { Optional findByRequestId(String requestId); IdempotencyRecord save(IdempotencyRecord record); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/package-info.java new file mode 100644 index 00000000..21c65499 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/package-info.java @@ -0,0 +1,5 @@ +/** + * Domain model for request idempotency tracking across retries of mutating + * operations. + */ +package com.iflytek.skillhub.domain.idempotency; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java index 947d414f..1ef7d247 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.domain.namespace; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Ensures newly active users belong to the built-in global namespace. + */ @Service public class GlobalNamespaceMembershipService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java index d798a7d5..5482628a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java @@ -2,6 +2,10 @@ package com.iflytek.skillhub.domain.namespace; import org.springframework.stereotype.Component; +/** + * Encapsulates namespace lifecycle rules that determine which management actions are currently + * allowed. + */ @Component public class NamespaceAccessPolicy { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java index dda4574b..c9db527f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -6,6 +6,10 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Applies namespace lifecycle transitions such as freeze, unfreeze, archive, + * and restore while recording audit history. + */ @Service public class NamespaceGovernanceService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java index 6387dab9..a07150f9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java @@ -6,6 +6,9 @@ import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for namespace membership lookups and member administration. + */ public interface NamespaceMemberRepository { Optional findByNamespaceIdAndUserId(Long namespaceId, String userId); List findByUserId(String userId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index 82c1c068..d8e8bc74 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -8,6 +8,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +/** + * Manages namespace membership additions, removals, and role changes under the + * namespace governance rules. + */ @Service public class NamespaceMemberService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index febf4ac0..775206e9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -6,6 +6,9 @@ import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for namespace aggregates and management-oriented reads. + */ public interface NamespaceRepository { Optional findById(Long id); List findByIdIn(List ids); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index fef5ea3d..32f20fd1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -7,6 +7,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Map; +/** + * Domain service for namespace lifecycle and membership-gated mutations. + */ @Service public class NamespaceService { @@ -22,6 +25,10 @@ public class NamespaceService { this.namespaceAccessPolicy = namespaceAccessPolicy; } + /** + * Creates a team namespace and grants the creator the owner role in the + * same transaction. + */ @Transactional public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) { SlugValidator.validate(slug); @@ -41,6 +48,9 @@ public class NamespaceService { return namespace; } + /** + * Updates mutable namespace profile fields after policy and role checks. + */ @Transactional public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl, String operatorUserId) { @@ -63,11 +73,19 @@ public class NamespaceService { return namespaceRepository.save(namespace); } + /** + * Loads a namespace by slug and fails with a business exception when it is + * missing. + */ public Namespace getNamespaceBySlug(String slug) { return namespaceRepository.findBySlug(slug) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + /** + * Returns archived namespaces only to callers that already belong to them; + * all other callers see archived namespaces as not found. + */ public Namespace getNamespaceBySlugForRead(String slug, String userId, Map userNsRoles) { Namespace namespace = getNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { @@ -84,6 +102,9 @@ public class NamespaceService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); } + /** + * Ensures the caller holds an owner or admin membership in the namespace. + */ public void assertAdminOrOwner(Long namespaceId, String userId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole) @@ -93,6 +114,9 @@ public class NamespaceService { } } + /** + * Ensures the caller is at least a member of the namespace. + */ public void assertMember(Long namespaceId, String userId) { namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java index dd3457e7..29c27677 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import java.util.Set; import java.util.regex.Pattern; +/** + * Validates and normalizes namespace-style slugs used across public identifiers. + */ public class SlugValidator { private static final int MIN_LENGTH = 2; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/package-info.java new file mode 100644 index 00000000..2700f3bc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/package-info.java @@ -0,0 +1,5 @@ +/** + * Namespace aggregate, membership rules, and governance policies that scope + * skills and collaboration permissions. + */ +package com.iflytek.skillhub.domain.namespace; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java index 291e3503..62349668 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java @@ -6,6 +6,9 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +/** + * Domain repository contract for abuse reports filed against skills. + */ public interface SkillReportRepository { SkillReport save(SkillReport report); Optional findById(Long id); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index cb96f00d..52cbd871 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -13,6 +13,10 @@ import java.time.Instant; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Handles skill abuse reports from submission through moderation outcome + * handling. + */ @Service public class SkillReportService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/package-info.java new file mode 100644 index 00000000..869ab7e2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/package-info.java @@ -0,0 +1,5 @@ +/** + * Reporting domain objects and services for user-submitted skill abuse or + * quality issues. + */ +package com.iflytek.skillhub.domain.report; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 4275959b..a6dc87c9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -4,6 +4,10 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.Optional; +/** + * Domain repository contract for promotion requests that copy or elevate one skill version into a + * target catalog entry. + */ public interface PromotionRequestRepository { PromotionRequest save(PromotionRequest request); Optional findById(Long id); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 7ee3c953..f627f749 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -22,6 +22,13 @@ import java.util.List; import java.util.Map; import java.util.Set; +/** + * Handles promotion requests that copy approved skills into the global + * namespace. + * + *

Promotion is intentionally modeled separately from normal review because + * it creates or updates a distinct target skill lineage. + */ @Service public class PromotionService { @@ -55,6 +62,10 @@ public class PromotionService { this.clock = clock; } + /** + * Submits a promotion request for a published source version using both + * namespace and platform roles for authorization. + */ @Transactional public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String userId, @@ -148,6 +159,10 @@ public class PromotionService { return promotionRequestRepository.save(request); } + /** + * Approves a promotion request and materializes a published copy of the + * source version in the target global namespace. + */ @Transactional public PromotionRequest approvePromotion(Long promotionId, String reviewerId, String comment, Set platformRoles) { @@ -230,6 +245,9 @@ public class PromotionService { return savedRequest; } + /** + * Rejects a pending promotion request without changing the source skill. + */ @Transactional public PromotionRequest rejectPromotion(Long promotionId, String reviewerId, String comment, Set platformRoles) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 21991214..bc5dae42 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -8,6 +8,9 @@ import org.springframework.stereotype.Component; import java.util.Map; import java.util.Set; +/** + * Centralizes review and promotion permission checks derived from namespace and platform roles. + */ @Component public class ReviewPermissionChecker { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 4802ac11..7133abb7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -29,6 +29,13 @@ import java.util.List; import java.util.Map; import java.util.Set; +/** + * Coordinates the review workflow for skill versions. + * + *

This service owns transitions between draft, pending review, approved, + * and rejected states together with the review task record that tracks the + * moderation decision. + */ @Service public class ReviewService { @@ -65,6 +72,10 @@ public class ReviewService { this.clock = clock; } + /** + * Submits a draft version into the review queue using both namespace roles + * and platform roles to determine permission. + */ @Transactional public ReviewTask submitReview(Long skillVersionId, String userId, @@ -98,6 +109,10 @@ public class ReviewService { } } + /** + * Legacy overload that evaluates submission rights only from namespace + * memberships. + */ @Transactional public ReviewTask submitReview(Long skillVersionId, String userId, @@ -130,6 +145,10 @@ public class ReviewService { } } + /** + * Approves a pending review task, publishes the underlying version, and + * emits downstream notifications and publication events. + */ @Transactional public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String comment, Map userNamespaceRoles, @@ -202,6 +221,10 @@ public class ReviewService { return reviewTaskRepository.findById(reviewTaskId).orElse(task); } + /** + * Rejects a pending review task and returns the underlying version to a + * non-published state with reviewer metadata captured on the task. + */ @Transactional public ReviewTask rejectReview(Long reviewTaskId, String reviewerId, String comment, Map userNamespaceRoles, @@ -244,6 +267,10 @@ public class ReviewService { return reviewTaskRepository.findById(reviewTaskId).orElse(task); } + /** + * Withdraws a previously submitted review request and puts the version back + * into draft so the owner can amend and resubmit it. + */ @Transactional public SkillVersion withdrawReview(Long skillVersionId, String userId) { ReviewTask task = reviewTaskRepository.findBySkillVersionIdAndStatus( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 9d3a3c49..5596f60a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.Optional; +/** + * Domain repository contract for moderation review tasks and their state transitions. + */ public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/package-info.java new file mode 100644 index 00000000..98386091 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/package-info.java @@ -0,0 +1,5 @@ +/** + * Review and promotion workflows that move skill versions through moderation + * and cross-namespace publication. + */ +package com.iflytek.skillhub.domain.review; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java index 670489fe..aab80d58 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.shared.exception; +/** + * Domain exception used when caller input violates business validation rules. + */ public class DomainBadRequestException extends LocalizedDomainException { public DomainBadRequestException(String messageCode, Object... messageArgs) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java index 1d5c9525..14807d15 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.shared.exception; +/** + * Domain exception used when the caller lacks permission for the requested business action. + */ public class DomainForbiddenException extends LocalizedDomainException { public DomainForbiddenException(String messageCode, Object... messageArgs) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java index 36b64c55..df387c69 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.shared.exception; +/** + * Domain exception used when a required business entity cannot be found. + */ public class DomainNotFoundException extends LocalizedDomainException { public DomainNotFoundException(String messageCode, Object... messageArgs) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java index c09d1919..35e55cd7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.shared.exception; +/** + * Base class for domain-layer exceptions that carry a localized message code and arguments. + */ public abstract class LocalizedDomainException extends RuntimeException { private final String messageCode; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/package-info.java new file mode 100644 index 00000000..ee685b3d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/package-info.java @@ -0,0 +1,5 @@ +/** + * Reusable business exceptions raised by domain services and translated at the + * application boundary. + */ +package com.iflytek.skillhub.domain.shared.exception; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java index 4cc0bd81..5a863645 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.domain.skill; import java.util.List; +/** + * Domain repository contract for files belonging to one published or draft skill version. + */ public interface SkillFileRepository { List findByVersionId(Long versionId); SkillFile save(SkillFile file); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index f3080f4f..d8ca3d44 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -6,6 +6,9 @@ import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for loading and persisting skill aggregates and common read models. + */ public interface SkillRepository { Optional findById(Long id); List findByIdIn(List ids); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTagRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTagRepository.java index f8cd1c1c..e45b09e6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTagRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTagRepository.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.domain.skill; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for persisted skill tags and tag lookups. + */ public interface SkillTagRepository { Optional findBySkillIdAndTagName(Long skillId, String tagName); List findBySkillId(Long skillId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 6a5739c7..eaeacd13 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.domain.skill; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for skill version history and publication-state queries. + */ public interface SkillVersionRepository { Optional findById(Long id); List findByIdIn(List ids); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java index 28685aa2..905621f9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.domain.skill; import java.util.Optional; +/** + * Domain repository contract for per-version counters such as download statistics. + */ public interface SkillVersionStatsRepository { Optional findBySkillVersionId(Long skillVersionId); void incrementDownloadCount(Long skillVersionId, Long skillId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 16980af1..f4632159 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -4,6 +4,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import java.util.Map; +/** + * Evaluates whether a caller may read a skill based on publication state, visibility, ownership, + * and namespace roles. + */ public class VisibilityChecker { public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index a461d2d2..36fc1ffc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -6,6 +6,10 @@ import org.yaml.snakeyaml.Yaml; import java.util.LinkedHashMap; import java.util.Map; +/** + * Parses `SKILL.md` frontmatter and body content into the normalized metadata model used by the + * publish pipeline. + */ public class SkillMetadataParser { private static final String FRONTMATTER_DELIMITER = "---"; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/package-info.java new file mode 100644 index 00000000..4fef333c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/package-info.java @@ -0,0 +1,4 @@ +/** + * Parsing and representation of metadata extracted from packaged skills. + */ +package com.iflytek.skillhub.domain.skill.metadata; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/package-info.java new file mode 100644 index 00000000..6c438d5e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/package-info.java @@ -0,0 +1,5 @@ +/** + * Core skill aggregate model including versions, files, visibility, and + * repositories shared across publication and consumption flows. + */ +package com.iflytek.skillhub.domain.skill; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index bc9678b1..8e83ba1b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -26,6 +26,12 @@ import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +/** + * Domain service that delivers packaged skills to callers. + * + *

It combines visibility checks, version resolution, object-storage access, + * and download tracking into a single download-oriented API. + */ @Service public class SkillDownloadService { private static final Logger log = LoggerFactory.getLogger(SkillDownloadService.class); @@ -77,6 +83,9 @@ public class SkillDownloadService { } } + /** + * Downloads the latest published version available to the caller. + */ public DownloadResult downloadLatest( String namespaceSlug, String skillSlug, @@ -97,6 +106,10 @@ public class SkillDownloadService { return downloadVersion(skill, version); } + /** + * Downloads an explicit version when the caller has permission to access + * the containing skill. + */ public DownloadResult downloadVersion( String namespaceSlug, String skillSlug, @@ -114,6 +127,9 @@ public class SkillDownloadService { return downloadVersion(skill, version); } + /** + * Downloads the version pointed to by a mutable tag name. + */ public DownloadResult downloadByTag( String namespaceSlug, String skillSlug, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index a96ccc67..999ae53c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -23,6 +23,10 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Handles governance-oriented mutations on skills and versions, including + * hiding, archiving, restoring, and destructive cleanup. + */ @Service public class SkillGovernanceService { @@ -157,6 +161,11 @@ public class SkillGovernanceService { objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId())); skillFileRepository.deleteByVersionId(version.getId()); skillVersionRepository.delete(version); + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } auditLogService.record( actorUserId, "DELETE_SKILL_VERSION", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 73b2d0e7..e374f3b9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -10,6 +10,10 @@ import java.util.List; import java.util.Map; import org.springframework.stereotype.Service; +/** + * Builds lightweight lifecycle projections that describe which skill version should be surfaced to + * a given viewer. + */ @Service public class SkillLifecycleProjectionService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index de5238b3..f43c7a25 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -45,6 +45,12 @@ import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +/** + * Publishes packaged skill artifacts into persisted skill and version records. + * + *

The service validates archive contents, parses metadata, stores files, + * creates review tasks when needed, and updates the skill's lifecycle pointer. + */ @Service public class SkillPublishService { @@ -100,6 +106,12 @@ public class SkillPublishService { this.clock = clock; } + /** + * Publishes an extracted package into the target namespace. + * + *

Super administrators may auto-publish, while regular publishers + * usually create a pending-review version. + */ @Transactional public PublishResult publishFromEntries( String namespaceSlug, @@ -110,6 +122,10 @@ public class SkillPublishService { return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false); } + /** + * Rebuilds a new version from an already published version by copying its + * stored files and rewriting the embedded metadata version field. + */ @Transactional public PublishResult rereleasePublishedVersion( Long skillId, @@ -220,6 +236,9 @@ public class SkillPublishService { return skillRepository.save(newSkill); }); + // Update visibility to match the latest publish request + skill.setVisibility(visibility); + if (skill.getStatus() == SkillStatus.ARCHIVED) { throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 55bf67c5..3a35e0a8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -31,6 +31,13 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +/** + * Read-side domain service for skill detail, version browsing, and packaged + * file inspection. + * + *

Unlike search, this service works from the authoritative skill model and + * applies viewer-specific visibility rules before returning data. + */ @Service public class SkillQueryService { @@ -171,6 +178,10 @@ public class SkillQueryService { ); } + /** + * Lists skills within a namespace after filtering out records the caller is + * not allowed to discover. + */ public Page listSkillsByNamespace( String namespaceSlug, String currentUserId, @@ -193,6 +204,10 @@ public class SkillQueryService { return new PageImpl<>(pageContent, pageable, accessibleSkills.size()); } + /** + * Returns metadata for a visible version, including the stored manifest and + * parsed metadata payload. + */ public SkillVersionDetailDTO getVersionDetail( String namespaceSlug, String skillSlug, @@ -247,6 +262,10 @@ public class SkillQueryService { return availableFiles(skillVersion.getId()); } + /** + * Opens a single file stream from object storage after verifying that the + * caller may inspect the requested version. + */ public InputStream getFileContent( String namespaceSlug, String skillSlug, @@ -328,6 +347,10 @@ public class SkillQueryService { return version.isDownloadReady(); } + /** + * Resolves a version selector such as an exact version, tag, or implicit + * latest reference into a concrete download target. + */ public ResolvedVersionDTO resolveVersion( String namespaceSlug, String skillSlug, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java index e4521da3..080172b1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java @@ -8,6 +8,9 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; +/** + * Resolves ambiguous namespace-slug pairs to the most appropriate skill record for the caller. + */ @Service public class SkillSlugResolutionService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index 07c3fdc0..6d7a801b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -12,6 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * Manages named tags that resolve to skill versions while enforcing + * visibility and membership constraints. + */ @Service public class SkillTagService { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/package-info.java new file mode 100644 index 00000000..6450f412 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Domain services that coordinate skill publication, lifecycle, query, and + * download use cases across multiple repositories and infrastructure ports. + */ +package com.iflytek.skillhub.domain.skill.service; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java index 86dca42c..c61e420b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -9,6 +9,10 @@ import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * Default pre-publish validator that scans text-like package files for likely secrets and + * accidental real credentials. + */ @Component public class BasicPrePublishValidator implements PrePublishValidator { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java index cd8e1ef8..44ae1cc5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.skill.validation; +/** + * Trivial validator used when no extra pre-publish checks are desired. + */ public class NoOpPrePublishValidator implements PrePublishValidator { @Override diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java index 5f64f151..deb08471 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java @@ -4,6 +4,10 @@ import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import java.util.List; +/** + * Extension point for content-aware validation that runs after package parsing but before a skill + * version is accepted for publishing. + */ public interface PrePublishValidator { ValidationResult validate(SkillPackageContext context); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index c4520a54..87a952b9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -9,6 +9,10 @@ import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Set; +/** + * Shared package-policy rules for path normalization, extension allowlists, and lightweight file + * signature validation. + */ public final class SkillPackagePolicy { public static final int MAX_FILE_COUNT = 100; @@ -16,14 +20,14 @@ public final class SkillPackagePolicy { public static final long MAX_TOTAL_PACKAGE_SIZE = 100 * 1024 * 1024; // 100MB public static final String SKILL_MD_PATH = "SKILL.md"; public static final Set ALLOWED_EXTENSIONS = Set.of( - // 文档 + // Documentation ".md", ".txt", ".json", ".yaml", ".yml", ".html", ".css", ".csv", ".pdf", - // 配置 + // Configuration ".toml", ".xml", ".ini", ".cfg", ".env", - // 脚本/语言 + // Scripts and source code ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", ".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash", - // 图片 + // Images ".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico" ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index a483612c..836a9eb5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -10,6 +10,10 @@ import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * Validates uploaded skill packages against structural, metadata, and size constraints before + * publish-time domain processing continues. + */ public class SkillPackageValidator { private static final Pattern YAML_LINE_COLUMN = Pattern.compile("line\\s+(\\d+),\\s+column\\s+(\\d+)"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/package-info.java new file mode 100644 index 00000000..fd6dc62e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/package-info.java @@ -0,0 +1,5 @@ +/** + * Validation policies for uploaded skill packages before they become persisted + * skill versions. + */ +package com.iflytek.skillhub.domain.skill.validation; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java index b29f5a4b..b628e598 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.domain.social; import java.util.Optional; +/** + * Domain repository contract for per-user ratings and rating aggregates on one skill. + */ public interface SkillRatingRepository { SkillRating save(SkillRating rating); Optional findBySkillIdAndUserId(Long skillId, String userId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 7d8a6235..95e67b0c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -10,6 +10,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +/** + * Domain service for creating or updating user ratings on skills and emitting + * the corresponding social event. + */ @Service public class SkillRatingService { private final SkillRatingRepository ratingRepository; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarRepository.java index 0cd9f1ab..2eabe344 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarRepository.java @@ -4,6 +4,9 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +/** + * Domain repository contract for skill star relationships and starred-skill pagination. + */ public interface SkillStarRepository { SkillStar save(SkillStar star); Optional findBySkillIdAndUserId(Long skillId, String userId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java index b1dd49bc..e9a5485c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java @@ -8,6 +8,9 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Domain service for starring and unstarring skills in an idempotent manner. + */ @Service public class SkillStarService { private final SkillStarRepository starRepository; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/package-info.java new file mode 100644 index 00000000..0a2ae09e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/package-info.java @@ -0,0 +1,5 @@ +/** + * Domain events emitted by social interactions so counters and projections can + * react asynchronously. + */ +package com.iflytek.skillhub.domain.social.event; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/package-info.java new file mode 100644 index 00000000..f9631312 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/package-info.java @@ -0,0 +1,5 @@ +/** + * Social interactions such as starring and rating that enrich the skill + * marketplace experience. + */ +package com.iflytek.skillhub.domain.social; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java index 0672f9b5..b3380ebb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java @@ -6,7 +6,6 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; - /** * Repository for {@link ProfileChangeRequest} entities. * Implementations are provided by the infra layer (JPA). diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index d2163c56..c0f4f295 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -6,6 +6,9 @@ import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; +/** + * Domain repository contract for user-account identity lookups and administrative searches. + */ public interface UserAccountRepository { Optional findById(String id); List findByIdIn(List ids); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/package-info.java new file mode 100644 index 00000000..e9e49d57 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/package-info.java @@ -0,0 +1,5 @@ +/** + * User account and profile moderation domain objects that back identity and + * profile management flows. + */ +package com.iflytek.skillhub.domain.user; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 572138b6..ba24d46d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -239,6 +239,35 @@ class SkillGovernanceServiceTest { verify(skillVersionRepository, never()).delete(any()); } + + @Test + void deleteVersion_updatesLatestVersionPointerWhenDeletingArchivedSkillsLatestDraft() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setLatestVersionId(2L); + + SkillVersion draftVersion = new SkillVersion(1L, "2.0.0-rc1", "owner"); + setField(draftVersion, "id", 2L); + draftVersion.setStatus(SkillVersionStatus.DRAFT); + + SkillVersion publishedVersion = new SkillVersion(1L, "1.0.0", "owner"); + setField(publishedVersion, "id", 3L); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + publishedVersion.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z")); + + given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(draftVersion, publishedVersion)); + given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) + .willReturn(java.util.List.of(publishedVersion)); + given(skillRepository.save(skill)).willReturn(skill); + given(skillFileRepository.findByVersionId(2L)).willReturn(java.util.List.of()); + + service.deleteVersion(skill, draftVersion, "owner", Map.of(), "127.0.0.1", "JUnit"); + + assertThat(skill.getLatestVersionId()).isEqualTo(3L); + verify(skillRepository).save(skill); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 97043f98..8580ff0b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -732,6 +732,49 @@ class SkillPublishServiceTest { verify(skillVersionRepository).save(pendingV1); } + @Test + void testPublishFromEntries_ShouldUpdateVisibilityOnExistingSkill() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 2.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of()); + + // Skill was created with PRIVATE visibility + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PRIVATE); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("2.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 20L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + // Act — publish with PUBLIC visibility on an existing PRIVATE skill + service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); + + // Assert — visibility should be updated to PUBLIC + assertEquals(SkillVisibility.PUBLIC, skill.getVisibility()); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java index 8d6b4606..fa57defa 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java @@ -9,6 +9,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; +/** + * JPA-backed audit-log repository that adds specification-based filtering for admin queries. + */ @Repository public interface AuditLogJpaRepository extends JpaRepository, JpaSpecificationExecutor, AuditLogRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java index 8eafd1d7..300e7a56 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java @@ -10,6 +10,10 @@ import org.springframework.stereotype.Repository; import java.time.Instant; +/** + * JPA repository that persists idempotency records and exposes cleanup operations used by + * background maintenance. + */ @Repository public interface JpaIdempotencyRecordRepository extends JpaRepository, IdempotencyRecordRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java index f60d8699..8996e226 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; +/** + * JPA-backed repository for per-user skill ratings and their derived aggregates. + */ @Repository public interface JpaSkillRatingRepository extends JpaRepository, SkillRatingRepository { Optional findBySkillIdAndUserId(Long skillId, String userId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index 6e9daecf..cf991282 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -12,6 +12,9 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * Primary JPA-backed adapter that fulfills the domain-level {@link SkillRepository} contract. + */ @Repository @Primary public class JpaSkillRepositoryAdapter implements SkillRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillStarRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillStarRepository.java index e75201e5..295273ac 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillStarRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillStarRepository.java @@ -8,6 +8,9 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +/** + * JPA-backed repository for skill star relationships and user star listings. + */ @Repository public interface JpaSkillStarRepository extends JpaRepository, SkillStarRepository { Optional findBySkillIdAndUserId(Long skillId, String userId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 1b21cc5d..7e7f3db0 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -11,6 +11,9 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * JPA-backed namespace repository that also fulfills the domain namespace repository contract. + */ @Repository public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java index 7a745cc3..13094042 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java @@ -10,6 +10,9 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * JPA-backed repository for namespace membership records and paged member listings. + */ @Repository public interface NamespaceMemberJpaRepository extends JpaRepository, NamespaceMemberRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index 88cf99ea..d3ab5db9 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -12,6 +12,9 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; +/** + * JPA-backed repository for promotion requests, including optimistic status updates. + */ @Repository public interface PromotionRequestJpaRepository extends JpaRepository, PromotionRequestRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 5ff37a7e..1fc4f7ea 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -12,6 +12,10 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; +/** + * JPA-backed repository for review tasks, including optimistic update support for moderation + * decisions. + */ @Repository public interface ReviewTaskJpaRepository extends JpaRepository, ReviewTaskRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java index db239fb4..21555436 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java @@ -7,6 +7,9 @@ import org.springframework.stereotype.Repository; import java.util.List; +/** + * JPA-backed repository for package files attached to one skill version. + */ @Repository public interface SkillFileJpaRepository extends JpaRepository, SkillFileRepository { List findByVersionId(Long versionId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index d784235a..cd98258e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -15,6 +15,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +/** + * Base Spring Data JPA repository for persisted skill aggregates and common skill queries. + */ @Repository public interface SkillJpaRepository extends JpaRepository, SkillRepository { List findByIdIn(List ids); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java index 1e352760..a88a0f47 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java @@ -9,6 +9,9 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +/** + * JPA-backed repository for skill abuse reports and report queues ordered by creation time. + */ public interface SkillReportJpaRepository extends JpaRepository, SkillReportRepository { boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); Page findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillTagJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillTagJpaRepository.java index 9a8aef3a..a232ebe6 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillTagJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillTagJpaRepository.java @@ -8,6 +8,9 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * JPA-backed repository for tags associated with a skill. + */ @Repository public interface SkillTagJpaRepository extends JpaRepository, SkillTagRepository { Optional findBySkillIdAndTagName(Long skillId, String tagName); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index e6095270..ca35771f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -11,6 +11,9 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * JPA-backed repository for skill version history and status-oriented version queries. + */ @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { List findByIdIn(List ids); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java index 3a926142..8dae9601 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java @@ -10,6 +10,9 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +/** + * JPA-backed repository for per-version statistics, including atomic download counter increments. + */ @Repository public interface SkillVersionStatsJpaRepository extends JpaRepository, SkillVersionStatsRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index 8fbb45cc..f2d4835c 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -11,6 +11,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +/** + * JPA-backed user-account repository that provides filtered admin search over account records. + */ @Repository public interface UserAccountJpaRepository extends JpaRepository, JpaSpecificationExecutor, UserAccountRepository { diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java index a4e461f6..5138ef62 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.domain.governance.UserNotificationRepository; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +/** + * JPA-backed repository for notifications shown in the governance inbox. + */ public interface UserNotificationJpaRepository extends JpaRepository, UserNotificationRepository { List findByUserIdOrderByCreatedAtDesc(String userId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/package-info.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/package-info.java new file mode 100644 index 00000000..fb0b3f50 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/package-info.java @@ -0,0 +1,5 @@ +/** + * JPA-backed adapters that implement domain repository contracts and other + * persistence-side projections. + */ +package com.iflytek.skillhub.infra.jpa; diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java index b92efe24..66fc338d 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java @@ -6,6 +6,10 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import org.springframework.stereotype.Service; +/** + * Lightweight embedding service that hashes lexical tokens into a fixed-size vector for approximate + * semantic ranking. + */ @Service public class HashingSearchEmbeddingService implements SearchEmbeddingService { private static final Pattern TOKEN_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+"); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java index 4eec4795..60273d88 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.search; +/** + * Converts text into a serialized vector form and evaluates similarity against stored vectors. + */ public interface SearchEmbeddingService { String embed(String text); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchIndexService.java index 5b02ccbb..ccb33bd7 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchIndexService.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.search; import java.util.List; +/** + * Writes and removes documents in the search index implementation. + */ public interface SearchIndexService { void index(SkillSearchDocument document); void batchIndex(List documents); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java index 595ab6af..3941bc5c 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.search; +/** + * Immutable search request model shared between application code and search implementations. + */ public record SearchQuery( String keyword, Long namespaceId, diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQueryService.java index 28cfd5d8..66c74e9c 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQueryService.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.search; +/** + * Read-side contract for executing skill searches against the configured search backend. + */ public interface SearchQueryService { SearchResult search(SearchQuery query); } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchRebuildService.java index 02ade3b4..ccd61e8e 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchRebuildService.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.search; +/** + * Rebuilds search index state from authoritative domain data. + */ public interface SearchRebuildService { void rebuildAll(); void rebuildByNamespace(Long namespaceId); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchResult.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchResult.java index 04ab826f..a0b6347d 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchResult.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchResult.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.search; import java.util.List; +/** + * Compact search response containing matching skill identifiers and pagination metadata. + */ public record SearchResult( List skillIds, long total, diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java index cb35d2c6..4f435e35 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.search; import java.util.Set; +/** + * Caller visibility context used by search implementations to filter results consistently. + */ public record SearchVisibilityScope( String userId, Set memberNamespaceIds, diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java index d40fb09f..bf5e413a 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.search; +/** + * Denormalized search document model written to and read from the search subsystem. + */ public record SkillSearchDocument( Long skillId, Long namespaceId, diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java index 74cc7eca..b0fcee58 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java @@ -10,6 +10,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +/** + * Reacts to committed skill lifecycle events and keeps the search index synchronized. + */ @Component public class SearchIndexEventListener { diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/package-info.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/package-info.java new file mode 100644 index 00000000..cf703287 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/package-info.java @@ -0,0 +1,5 @@ +/** + * Search indexing listeners and event adapters that keep search documents in + * sync with domain changes. + */ +package com.iflytek.skillhub.search.event; diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/package-info.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/package-info.java new file mode 100644 index 00000000..5ed7e6f5 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/package-info.java @@ -0,0 +1,4 @@ +/** + * Search-facing ports and DTOs that provide skill discovery capabilities. + */ +package com.iflytek.skillhub.search; diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java index b856b82a..6025c7a2 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java @@ -11,6 +11,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +/** + * PostgreSQL-backed search index writer that stores searchable documents and semantic vectors. + */ @Service public class PostgresFullTextIndexService implements SearchIndexService { diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 158bcb25..d5eb7e43 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -19,6 +19,13 @@ import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +/** + * PostgreSQL-backed implementation of {@link SearchQueryService}. + * + *

The query pipeline combines structured visibility filters, full-text + * ranking, and an optional semantic re-ranking pass over a bounded candidate + * set. + */ @Service public class PostgresFullTextQueryService implements SearchQueryService { private static final Pattern QUERY_TERM_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+"); @@ -56,6 +63,10 @@ public class PostgresFullTextQueryService implements SearchQueryService { this.maxCandidates = maxCandidates; } + /** + * Executes a search query against the denormalized search document table + * and optionally re-ranks candidates using embeddings. + */ @Override public SearchResult search(SearchQuery query) { String normalizedKeyword = normalizeKeyword(query.keyword()); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 59bd51ef..c5d13f82 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -1,32 +1,54 @@ package com.iflytek.skillhub.search.postgres; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; import com.iflytek.skillhub.search.SkillSearchDocument; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +/** + * Reconstructs PostgreSQL search documents from canonical skill and namespace records. + */ @Service public class PostgresSearchRebuildService implements SearchRebuildService { + private static final Set RESERVED_FRONTMATTER_FIELDS = Set.of("name", "description", "version"); + private static final Set KEYWORD_FIELD_NAMES = Set.of("keywords", "keyword", "tags", "tag"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; private final SearchIndexService searchIndexService; + private final ObjectMapper objectMapper; public PostgresSearchRebuildService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, SearchIndexService searchIndexService) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; this.searchIndexService = searchIndexService; + this.objectMapper = new ObjectMapper(); } @Override @@ -58,16 +80,112 @@ public class PostgresSearchRebuildService implements SearchRebuildService { toDocument(skillOpt.get()).ifPresent(searchIndexService::index); } - private String buildSearchText(Skill skill) { - StringBuilder sb = new StringBuilder(); - if (skill.getDisplayName() != null) { - sb.append(skill.getDisplayName()).append(" "); + private SearchIndexPayload buildSearchPayload(Skill skill) { + List searchParts = new ArrayList<>(); + addPart(searchParts, skill.getDisplayName()); + addPart(searchParts, skill.getSlug()); + addPart(searchParts, skill.getSummary()); + + Set keywords = new TreeSet<>(); + resolveLatestVersion(skill) + .map(this::extractParsedMetadata) + .map(metadata -> metadata.get("frontmatter")) + .map(this::asMap) + .ifPresent(frontmatter -> appendFrontmatter(frontmatter, keywords, searchParts)); + + return new SearchIndexPayload( + String.join(", ", keywords), + String.join(" ", searchParts).trim() + ); + } + + private Optional resolveLatestVersion(Skill skill) { + if (skill.getLatestVersionId() == null) { + return Optional.empty(); } - sb.append(skill.getSlug()).append(" "); - if (skill.getSummary() != null) { - sb.append(skill.getSummary()).append(" "); + return skillVersionRepository.findById(skill.getLatestVersionId()); + } + + private Map extractParsedMetadata(SkillVersion version) { + String metadataJson = version.getParsedMetadataJson(); + if (metadataJson == null || metadataJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(metadataJson, MAP_TYPE); + } catch (Exception e) { + return Map.of(); + } + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + if (value instanceof Map map) { + return map.entrySet().stream() + .filter(entry -> entry.getKey() != null) + .collect(Collectors.toMap(entry -> String.valueOf(entry.getKey()), Map.Entry::getValue)); + } + return Map.of(); + } + + private void appendFrontmatter(Map frontmatter, Set keywords, List searchParts) { + for (Map.Entry entry : frontmatter.entrySet()) { + String fieldName = entry.getKey(); + Object value = entry.getValue(); + if (value == null) { + continue; + } + + if (KEYWORD_FIELD_NAMES.contains(fieldName.toLowerCase())) { + flattenToStrings(value).forEach(keyword -> { + String normalized = keyword.trim(); + if (!normalized.isBlank()) { + keywords.add(normalized); + } + }); + } + + if (!RESERVED_FRONTMATTER_FIELDS.contains(fieldName.toLowerCase())) { + addPart(searchParts, fieldName); + flattenToStrings(value).forEach(text -> addPart(searchParts, text)); + } + } + } + + private List flattenToStrings(Object value) { + if (value instanceof String text) { + return List.of(text); + } + if (value instanceof Number || value instanceof Boolean) { + return List.of(String.valueOf(value)); + } + if (value instanceof Map map) { + List values = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() != null) { + values.add(String.valueOf(entry.getKey())); + } + values.addAll(flattenToStrings(entry.getValue())); + } + return values; + } + if (value instanceof Collection collection) { + return collection.stream() + .filter(Objects::nonNull) + .flatMap(item -> flattenToStrings(item).stream()) + .toList(); + } + return List.of(String.valueOf(value)); + } + + private void addPart(List parts, String value) { + if (value == null) { + return; + } + String normalized = value.trim(); + if (!normalized.isBlank()) { + parts.add(normalized); } - return sb.toString().trim(); } private Optional toDocument(Skill skill) { @@ -77,7 +195,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } Namespace namespace = namespaceOpt.get(); - String searchText = buildSearchText(skill); + SearchIndexPayload payload = buildSearchPayload(skill); return Optional.of(new SkillSearchDocument( skill.getId(), @@ -86,11 +204,14 @@ public class PostgresSearchRebuildService implements SearchRebuildService { skill.getOwnerId(), skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(), skill.getSummary(), - "", - searchText, + payload.keywords(), + payload.searchText(), null, skill.getVisibility().name(), skill.getStatus().name() )); } + + private record SearchIndexPayload(String keywords, String searchText) { + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/package-info.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/package-info.java new file mode 100644 index 00000000..6526225b --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/package-info.java @@ -0,0 +1,4 @@ +/** + * PostgreSQL-specific search implementations and document models. + */ +package com.iflytek.skillhub.search.postgres; diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java new file mode 100644 index 00000000..9e186727 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.search.SearchIndexService; +import com.iflytek.skillhub.search.SkillSearchDocument; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PostgresSearchRebuildServiceTest { + + @Test + void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "smart-agent", "owner-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Smart Agent"); + skill.setSummary("Builds workflows"); + skill.setLatestVersionId(99L); + + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + + SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1"); + version.setParsedMetadataJson(""" + { + "name": "Smart Agent", + "description": "Builds workflows", + "version": "1.2.0", + "body": "# ignored", + "frontmatter": { + "name": "Smart Agent", + "description": "Builds workflows", + "version": "1.2.0", + "author": "Jane Doe", + "tags": ["automation", "agentic"], + "keywords": ["workflow", "assistant"], + "config": { + "provider": "openai" + } + } + } + """); + + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = new PostgresSearchRebuildService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillSearchDocument.class); + verify(searchIndexService).index(captor.capture()); + + SkillSearchDocument document = captor.getValue(); + assertThat(document.keywords()).isEqualTo("agentic, assistant, automation, workflow"); + assertThat(document.searchText()).contains("Smart Agent"); + assertThat(document.searchText()).contains("smart-agent"); + assertThat(document.searchText()).contains("Builds workflows"); + assertThat(document.searchText()).contains("author"); + assertThat(document.searchText()).contains("Jane Doe"); + assertThat(document.searchText()).contains("config"); + assertThat(document.searchText()).contains("provider"); + assertThat(document.searchText()).contains("openai"); + assertThat(document.searchText()).doesNotContain("# ignored"); + } +} diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index ba87c7be..86b6b590 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -9,6 +9,12 @@ import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.util.List; +/** + * Local-disk implementation of {@link ObjectStorageService}. + * + *

This adapter is intended for development or single-node deployments where + * storing objects on the application filesystem is acceptable. + */ @Service @ConditionalOnProperty(name = "skillhub.storage.provider", havingValue = "local", matchIfMissing = true) public class LocalFileStorageService implements ObjectStorageService { @@ -18,6 +24,10 @@ public class LocalFileStorageService implements ObjectStorageService { this.basePath = Paths.get(properties.getLocal().getBasePath()).toAbsolutePath().normalize(); } + /** + * Stores the incoming stream atomically by writing to a temporary sibling + * file before replacing the final path. + */ @Override public void putObject(String key, InputStream data, long size, String contentType) { try { @@ -31,12 +41,18 @@ public class LocalFileStorageService implements ObjectStorageService { } catch (IOException e) { throw new StorageAccessException("putObject", key, e); } } + /** + * Opens the stored object as a streaming input. + */ @Override public InputStream getObject(String key) { try { return Files.newInputStream(resolve(key)); } catch (IOException e) { throw new StorageAccessException("getObject", key, e); } } + /** + * Resolves and deletes a single stored object if it exists. + */ @Override public void deleteObject(String key) { try { Files.deleteIfExists(resolve(key)); } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectMetadata.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectMetadata.java index 8652dfba..f090e038 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectMetadata.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectMetadata.java @@ -2,4 +2,7 @@ package com.iflytek.skillhub.storage; import java.time.Instant; +/** + * Minimal metadata returned by object-storage providers for one stored object. + */ public record ObjectMetadata(long size, String contentType, Instant lastModified) {} diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java index 5873f36b..1ba1b45a 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java @@ -4,6 +4,9 @@ import java.io.InputStream; import java.time.Duration; import java.util.List; +/** + * Storage abstraction for binary skill assets and bundles regardless of the backing provider. + */ public interface ObjectStorageService { void putObject(String key, InputStream data, long size, String contentType); InputStream getObject(String key); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index ae50486c..50062e85 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -22,6 +22,10 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; +/** + * S3-compatible object storage implementation used for persisted skill packages and generated + * download URLs. + */ @Service @ConditionalOnProperty(name = "skillhub.storage.provider", havingValue = "s3") public class S3StorageService implements ObjectStorageService { diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java index 81f7db15..7f5790bc 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageAccessException.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.storage; +/** + * Wraps provider-specific storage failures with normalized operation and object-key context. + */ public class StorageAccessException extends RuntimeException { private final String operation; diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/package-info.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/package-info.java new file mode 100644 index 00000000..ef098393 --- /dev/null +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/package-info.java @@ -0,0 +1,5 @@ +/** + * Object storage abstraction and concrete storage implementations used for + * packaged skill assets. + */ +package com.iflytek.skillhub.storage; diff --git a/web/package.json b/web/package.json index 32faa59b..e6189d53 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,7 @@ "scripts": { "install:ci": "pnpm install --frozen-lockfile", "dev": "vite", - "build": "tsc -b && vite build && node scripts/inject-docs.mjs", + "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest run", "typecheck": "tsc --noEmit", @@ -48,7 +48,6 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", - "marked": "^15.0.12", "openapi-typescript": "^7.6.1", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 09873f84..d86a7f30 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -105,9 +105,6 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.5 version: 0.4.26(eslint@8.57.1) - marked: - specifier: ^15.0.12 - version: 15.0.12 openapi-typescript: specifier: ^7.6.1 version: 7.13.0(typescript@5.9.3) @@ -1667,11 +1664,6 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} - hasBin: true - mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -3985,8 +3977,6 @@ snapshots: markdown-table@3.0.4: {} - marked@15.0.12: {} - mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 00000000..b30cf757 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://skill.xfyun.cn/sitemap.xml \ No newline at end of file diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml new file mode 100644 index 00000000..064bdae7 --- /dev/null +++ b/web/public/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://skill.xfyun.cn/ + + + https://skill.xfyun.cn/registry/skill.md + + diff --git a/web/scripts/inject-docs.mjs b/web/scripts/inject-docs.mjs deleted file mode 100644 index fc0d7a6d..00000000 --- a/web/scripts/inject-docs.mjs +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node - -import { readFileSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { marked } from 'marked'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Paths -const projectRoot = join(__dirname, '../..'); -const docPath = join(projectRoot, 'docs/openclaw-integration-en.md'); -const distIndexPath = join(__dirname, '../dist/index.html'); - -console.log('📄 Reading markdown document...'); -const markdownContent = readFileSync(docPath, 'utf-8'); - -console.log('🔄 Converting markdown to HTML...'); -const htmlContent = marked.parse(markdownContent); - -console.log('📝 Reading dist/index.html...'); -const indexHtml = readFileSync(distIndexPath, 'utf-8'); - -// Create the hidden SEO container -const seoContainer = ` - -

-`; - -// Inject before
-const injectedHtml = indexHtml.replace( - /
/, - `${seoContainer}\n
` -); - -console.log('💾 Writing updated index.html...'); -writeFileSync(distIndexPath, injectedHtml, 'utf-8'); - -console.log('✅ Documentation injected successfully!'); -console.log(` - Source: ${docPath}`); -console.log(` - Target: ${distIndexPath}`); -console.log(` - Content size: ~${Math.round(htmlContent.length / 1024)}KB`); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 38418658..9cfd5d90 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,6 +33,12 @@ import type { import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' +/** + * Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers. + * + * This module centralizes runtime-config lookup, CSRF handling, localized request headers, envelope + * unwrapping, and exported API groups used throughout feature hooks. + */ export { ApiError } export const WEB_API_PREFIX = '/api/web' @@ -116,6 +122,8 @@ function hasDataProperty(value: unknown): value is { data: T } { } async function unwrap(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise { + // The backend returns a standard response envelope; normalize both success and error payloads + // here so feature hooks can work with plain values and one ApiError shape. const { data, error, response } = await promise const envelope = isApiEnvelope(data) ? data : isApiEnvelope(error) ? error : null @@ -823,6 +831,13 @@ export const governanceApi = { headers: getCsrfHeaders(), }) }, + + async rebuildSearchIndex(): Promise { + await fetchJson('/api/v1/admin/search/rebuild', { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, } export const meApi = { @@ -872,7 +887,6 @@ export const profileApi = { }> { return fetchJson('/api/v1/user/profile') }, - async updateProfile(request: { displayName: string }): Promise<{ status: string }> { return fetchJson<{ status: string }>('/api/v1/user/profile', { method: 'PATCH', diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 2f25b34e..613c2cdf 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -5,6 +5,12 @@ import { useAuth } from '@/features/auth/use-auth' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { UserMenu } from '@/shared/components/user-menu' +/** + * Application shell shared by all routed pages. + * + * It owns the global header, footer, language switcher, auth-aware navigation, and suspense + * fallback used while lazy route modules are loading. + */ export function Layout() { const { t } = useTranslation() const pathname = useRouterState({ select: (s) => s.location.pathname }) @@ -25,7 +31,7 @@ export function Layout() { const isActive = (to: string, exact?: boolean) => { if (exact) return pathname === to - // 精确匹配,避免父路径也被高亮 + // Keep matching strict so parent dashboard paths do not highlight unrelated child links. return pathname === to } diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 37880816..ef53ada6 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -4,6 +4,12 @@ import { Toaster } from '@/shared/components/toaster' import { handleApiError } from '@/shared/lib/api-error' import { router } from './router' +/** + * Front-end application root. + * + * It wires TanStack Query, TanStack Router, and the global toaster so all pages share one query + * cache and one navigation context. + */ const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 92c2576f..c8d54063 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -5,6 +5,12 @@ import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { normalizeSearchQuery } from '@/shared/lib/search-query' +/** + * Central route registry for the SkillHub web app. + * + * This file keeps route declarations, auth redirects, role-based wrappers, and search-param + * normalization in one place so route behavior remains explicit. + */ // Capture original URL before TanStack Router rewrites it const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' @@ -15,6 +21,8 @@ function createLazyRouteComponent>( importer: () => Promise, exportName: keyof TModule, ) { + // Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave + // consistently across public and dashboard pages. const LazyComponent = lazy(async () => { const module = await importer() return { default: module[exportName] as ComponentType } @@ -40,6 +48,7 @@ function createRoleProtectedRouteComponent) { @@ -136,6 +145,7 @@ function buildReturnTo(location: { pathname: string; searchStr?: string; hash?: } async function requireAuth({ location }: { location: { pathname: string; searchStr?: string; hash?: string } }) { + // Resolve the current session before entering protected areas and preserve the full return URL. const user = await getCurrentUser() if (!user) { throw redirect({ diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts index 1e1e04d2..8c8ddc6c 100644 --- a/web/src/bootstrap.ts +++ b/web/src/bootstrap.ts @@ -1,3 +1,9 @@ +/** + * Bootstraps runtime configuration before the React bundle mounts. + * + * Deployments inject `/runtime-config.js` at startup, and this file guarantees the app sees either + * that config or a safe fallback object before importing the main entry. + */ async function loadRuntimeConfig() { await new Promise((resolve, reject) => { const script = document.createElement('script') diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index ac1bf910..42c17d90 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -1,24 +1,27 @@ --- name: skillhub-registry -description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a self-hosted skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. +description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. --- # SkillHub Registry -Use this skill when you need to work with a SkillHub deployment: search skills, inspect metadata, install a package, or publish a new version. +Use this skill when you need to work with a SkillHub registry: search skills, inspect metadata, install a package, or publish a new version. > Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. ## What SkillHub Is -SkillHub is a self-hosted, enterprise-oriented skill registry. It stores versioned skill packages, supports namespace-based governance, and keeps `SKILL.md` compatibility with OpenSkills-style packages. +SkillHub is an enterprise-oriented skill registry. It stores versioned skill packages, supports namespace-based skill management, and keeps `SKILL.md` compatibility with OpenSkills-style packages. Key facts: - Internal coordinates use `@{namespace}/{skill_slug}`. -- ClawHub-compatible clients use a canonical slug instead. +- If using the clawhub CLI, the compatible format is `{namespace}--{skill_slug}`. +- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug instead. - `latest` always means the latest published version, never draft or pending review. - Public skills in `@global` can be downloaded anonymously. +- If no namespace is specified, it defaults to `@global`. +- `{skill_slug}` can be used instead of `global--{skill_slug}` - Team namespace skills and non-public skills require authentication. ## Configure The CLI @@ -29,6 +32,13 @@ Point `clawhub` at the SkillHub base URL: export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com ``` +Alternatively, use the `--registry` parameter every time, for example: + +```bash +npx clawhub install my-skill --registry https://skillhub.your-company.com +``` + + If you need authenticated access, provide an API token: ```bash @@ -44,10 +54,10 @@ curl https://skillhub.your-company.com/.well-known/clawhub.json Expected response: ```json -{ "apiBase": "/api/v1" } +{"apiBase":"/api/v1"} ``` -## Coordinate Rules +## Coordinate Rules - IMPORTANT SkillHub has two naming forms: @@ -130,64 +140,6 @@ If a request fails with `403`, check: SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. -Minimum valid `SKILL.md` frontmatter: - -```yaml ---- -name: my-skill -description: When to use this skill ---- -``` - -Required structure: - -```text -my-skill/ -├── SKILL.md -├── references/ -├── scripts/ -└── assets/ -``` - -Contract notes: - -- `name` and `description` are required. -- `name` becomes the immutable skill slug on first publish. -- `description` becomes the registry summary. -- `references/`, `scripts/`, and `assets/` are optional. -- The package is treated as a text-first resource bundle, not a binary artifact bucket. - ## Publishing Guidance -Before publishing: - -1. Ensure `SKILL.md` exists at the package root. -2. Keep the skill name in kebab-case. -3. Make sure the version you are publishing is semver-compatible. -4. Avoid relying on `latest` as a rollback tool; SkillHub keeps `latest` automatically pinned to the newest published version. -5. Use custom tags like `beta` or `stable` for release channels when needed. - -## When To Use Raw HTTP - -Use direct HTTP only for server debugging, contract testing, or compatibility work. Relevant endpoints exposed by the current codebase include: - -- `GET /.well-known/clawhub.json` -- `GET /api/v1/search` -- `GET /api/v1/resolve` -- `GET /api/v1/download/{slug}` -- `GET /api/v1/skills/{slug}` -- `POST /api/v1/publish` -- `GET /api/v1/whoami` - -For normal registry usage, stay on the `clawhub` CLI. - -## Project References - -Read these local documents when you need more detail about SkillHub behavior: - -- `docs/00-product-direction.md` -- `docs/06-api-design.md` -- `docs/07-skill-protocol.md` -- `docs/14-skill-lifecycle.md` -- `docs/openclaw-integration.md` -- `README.md` +Just need to follow the OpenSkills-style standards. diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index 45c781bc..be9c7743 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -3,6 +3,9 @@ import { adminApi } from '@/api/client' import type { AdminUser } from '@/api/types' export type { AdminUser } from '@/api/types' +/** + * Admin user-management hooks for listing users and mutating their role or account status. + */ export interface AdminUsersParams { search?: string status?: string @@ -42,6 +45,8 @@ export function useUpdateUserRole() { mutationFn: ({ userId, role }: { userId: string; role: string }) => updateUserRole(userId, role), onSuccess: () => { + // User role changes affect both the admin list and the current session when an administrator + // edits their own account. queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts index c55ab5a7..e4569a08 100644 --- a/web/src/features/admin/use-audit-log.ts +++ b/web/src/features/admin/use-audit-log.ts @@ -2,6 +2,9 @@ import { useQuery } from '@tanstack/react-query' import { adminApi } from '@/api/client' import type { AuditLogItem } from '@/api/types' +/** + * Admin audit-log query hook with server-side filtering and pagination parameters. + */ export interface AuditLogParams { action?: string userId?: string diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index a7dad6d1..ba0bc040 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -6,6 +6,9 @@ interface LoginButtonProps { returnTo?: string } +/** + * Renders OAuth login buttons from the auth-method catalog returned by the backend. + */ export function LoginButton({ returnTo }: LoginButtonProps) { const { t } = useTranslation() const { data, isLoading } = useAuthMethods(returnTo) diff --git a/web/src/features/auth/session-bootstrap-entry.tsx b/web/src/features/auth/session-bootstrap-entry.tsx index eb2651e2..ed904dc2 100644 --- a/web/src/features/auth/session-bootstrap-entry.tsx +++ b/web/src/features/auth/session-bootstrap-entry.tsx @@ -9,6 +9,10 @@ interface SessionBootstrapEntryProps { methodDisplayName?: string } +/** + * Optional login entry that attempts to bootstrap a browser session from an upstream enterprise + * identity before showing manual login choices. + */ export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: SessionBootstrapEntryProps) { const { t } = useTranslation() const config = getSessionBootstrapRuntimeConfig() @@ -17,6 +21,8 @@ export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: Se const providerName = methodDisplayName || t('login.enterpriseSsoTitle') useEffect(() => { + // Auto-bootstrap should only be attempted once per page load; if it fails the page must remain + // usable for normal login options. if (!config.enabled || !config.provider || !config.auto || attemptedRef.current) { return } diff --git a/web/src/features/auth/use-auth-methods.ts b/web/src/features/auth/use-auth-methods.ts index b05c82ce..864d054a 100644 --- a/web/src/features/auth/use-auth-methods.ts +++ b/web/src/features/auth/use-auth-methods.ts @@ -2,6 +2,9 @@ import { useQuery } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { AuthMethod } from '@/api/types' +/** + * Loads the backend-advertised authentication methods for the current entry point. + */ export function useAuthMethods(returnTo?: string) { return useQuery({ queryKey: ['auth', 'methods', returnTo ?? ''], diff --git a/web/src/features/auth/use-auth.ts b/web/src/features/auth/use-auth.ts index 4a9d66d5..7f889430 100644 --- a/web/src/features/auth/use-auth.ts +++ b/web/src/features/auth/use-auth.ts @@ -2,6 +2,12 @@ import { useQuery } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { User } from '@/api/types' +/** + * Auth session hook used throughout the app. + * + * It polls and refetches the current user aggressively enough to keep role-based UI and protected + * routes aligned with the latest server session state. + */ export function getAuthQueryOptions(enabled = true) { return { queryKey: ['auth', 'me'] as const, diff --git a/web/src/features/auth/use-local-auth.ts b/web/src/features/auth/use-local-auth.ts index f92e7906..e1a33fc6 100644 --- a/web/src/features/auth/use-local-auth.ts +++ b/web/src/features/auth/use-local-auth.ts @@ -2,6 +2,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { LocalLoginRequest, LocalRegisterRequest, User } from '@/api/types' +/** + * Local-account auth mutations for classic username-password login and registration. + */ export function useLocalLogin() { const queryClient = useQueryClient() diff --git a/web/src/features/auth/use-password-login.ts b/web/src/features/auth/use-password-login.ts index a1ce3716..a5f4f39d 100644 --- a/web/src/features/auth/use-password-login.ts +++ b/web/src/features/auth/use-password-login.ts @@ -3,6 +3,10 @@ import { authApi, getDirectAuthRuntimeConfig } from '@/api/client' import { ApiError } from '@/shared/lib/api-error' import type { LocalLoginRequest, User } from '@/api/types' +/** + * Password-login mutation that can switch between local auth and direct upstream auth based on + * runtime configuration. + */ export function usePasswordLogin() { const queryClient = useQueryClient() const directAuthConfig = getDirectAuthRuntimeConfig() diff --git a/web/src/features/auth/use-session-bootstrap.ts b/web/src/features/auth/use-session-bootstrap.ts index eb9b7d71..1c1269ce 100644 --- a/web/src/features/auth/use-session-bootstrap.ts +++ b/web/src/features/auth/use-session-bootstrap.ts @@ -2,6 +2,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { User } from '@/api/types' +/** + * Session-bootstrap mutation used when the backend can mint a browser session from an upstream + * platform identity. + */ export function useSessionBootstrap() { const queryClient = useQueryClient() diff --git a/web/src/features/governance/governance-activity.tsx b/web/src/features/governance/governance-activity.tsx index 47a9335f..197b30cb 100644 --- a/web/src/features/governance/governance-activity.tsx +++ b/web/src/features/governance/governance-activity.tsx @@ -8,6 +8,11 @@ interface GovernanceActivityProps { isLoading: boolean } +/** + * Renders a read-only timeline of governance actions surfaced on the dashboard. + * The component stays intentionally thin: formatting and empty/loading states live + * here, while filtering and retrieval are owned by the parent query layer. + */ export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps) { const { t, i18n } = useTranslation() diff --git a/web/src/features/governance/governance-inbox.tsx b/web/src/features/governance/governance-inbox.tsx index e998f60c..45caac4a 100644 --- a/web/src/features/governance/governance-inbox.tsx +++ b/web/src/features/governance/governance-inbox.tsx @@ -10,6 +10,11 @@ interface GovernanceInboxProps { isLoading: boolean } +/** + * Shows actionable governance work items and routes each item type to the most + * relevant review surface. Navigation stays local because the backend payload is + * intentionally generic and does not expose a single canonical frontend route. + */ export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) { const { t, i18n } = useTranslation() const navigate = useNavigate() @@ -23,6 +28,8 @@ export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) { } const openItem = (item: GovernanceInboxItem) => { + // Inbox items aggregate multiple workflows, so the UI resolves the target + // screen from item type instead of relying on one backend-provided URL. if (item.type === 'REVIEW') { navigate({ to: `/dashboard/reviews/${item.id}` }) return diff --git a/web/src/features/governance/governance-notifications.tsx b/web/src/features/governance/governance-notifications.tsx index 891a7af3..ce7e26e9 100644 --- a/web/src/features/governance/governance-notifications.tsx +++ b/web/src/features/governance/governance-notifications.tsx @@ -11,6 +11,11 @@ interface GovernanceNotificationsProps { isMarkingRead: boolean } +/** + * Displays governance notifications and exposes the minimal interaction needed + * by the dashboard: mark unread items as read. More complex navigation remains + * outside this component because notification payloads are heterogeneous. + */ export function GovernanceNotifications({ items, isLoading, onMarkRead, isMarkingRead }: GovernanceNotificationsProps) { const { t, i18n } = useTranslation() diff --git a/web/src/features/governance/use-governance.ts b/web/src/features/governance/use-governance.ts index f55a9e3c..57cfaa4b 100644 --- a/web/src/features/governance/use-governance.ts +++ b/web/src/features/governance/use-governance.ts @@ -1,6 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { governanceApi } from '@/api/client' +/** + * Governance query and mutation hooks shared by dashboard moderation pages. + */ export function useGovernanceSummary() { return useQuery({ queryKey: ['governance', 'summary'], @@ -40,7 +43,15 @@ export function useMarkGovernanceNotificationRead() { return useMutation({ mutationFn: (id: number) => governanceApi.markNotificationRead(id), onSuccess: () => { + // Notifications affect the global governance badge state, so keep the whole notification list + // fresh after marking one item as read. queryClient.invalidateQueries({ queryKey: ['governance', 'notifications'] }) }, }) } + +export function useRebuildSearchIndex() { + return useMutation({ + mutationFn: () => governanceApi.rebuildSearchIndex(), + }) +} diff --git a/web/src/features/namespace/add-namespace-member-dialog.tsx b/web/src/features/namespace/add-namespace-member-dialog.tsx index 4dd077d2..581ce169 100644 --- a/web/src/features/namespace/add-namespace-member-dialog.tsx +++ b/web/src/features/namespace/add-namespace-member-dialog.tsx @@ -24,6 +24,11 @@ interface AddNamespaceMemberDialogProps { const ROLE_OPTIONS: NamespaceRole[] = ['MEMBER', 'ADMIN'] +/** + * Handles the namespace member invitation flow, including optional candidate + * lookup and direct user-id entry. Local state is reset on close so reopening + * the dialog never leaks stale search or validation state from prior attempts. + */ export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberDialogProps) { const { t } = useTranslation() const addMemberMutation = useAddNamespaceMember() @@ -60,6 +65,8 @@ export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberD const handleSearch = () => { const keyword = searchInput.trim() + // Short keywords usually generate low-signal candidate lists and create + // needless backend traffic, so the UI enforces a small minimum length. if (keyword.length > 0 && keyword.length < 2) { setSearchError(t('members.searchTooShort')) return diff --git a/web/src/features/namespace/create-namespace-dialog.tsx b/web/src/features/namespace/create-namespace-dialog.tsx index 733e402b..57acaf06 100644 --- a/web/src/features/namespace/create-namespace-dialog.tsx +++ b/web/src/features/namespace/create-namespace-dialog.tsx @@ -46,6 +46,10 @@ const MIN_SLUG_LENGTH = 2 const MAX_DISPLAY_NAME_LENGTH = 128 const MAX_DESCRIPTION_LENGTH = 512 +/** + * Performs client-side validation that mirrors the backend namespace rules so + * users get immediate feedback before the create request is sent. + */ function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, options?: Record) => string): FieldErrors { const errors: FieldErrors = {} const slug = request.slug.trim() @@ -77,6 +81,11 @@ function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, opti return errors } +/** + * Collects and validates namespace creation input before delegating the actual + * mutation to the shared query layer. The dialog owns normalization because the + * same slug/display-name constraints are also reflected in the local UI copy. + */ export function CreateNamespaceDialog({ children }: CreateNamespaceDialogProps) { const { t } = useTranslation() const createMutation = useCreateNamespace() diff --git a/web/src/features/namespace/namespace-header.tsx b/web/src/features/namespace/namespace-header.tsx index cda6c971..ff3c0f26 100644 --- a/web/src/features/namespace/namespace-header.tsx +++ b/web/src/features/namespace/namespace-header.tsx @@ -7,6 +7,9 @@ interface NamespaceHeaderProps { namespace: Namespace } +/** + * Header block for namespace pages and namespace-oriented dashboard views. + */ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) { const { t } = useTranslation() const statusLabel = namespace.status === 'FROZEN' diff --git a/web/src/features/namespace/use-my-namespaces.ts b/web/src/features/namespace/use-my-namespaces.ts index 88faa353..f2aff2c0 100644 --- a/web/src/features/namespace/use-my-namespaces.ts +++ b/web/src/features/namespace/use-my-namespaces.ts @@ -1 +1,5 @@ +/** + * Preserves a feature-local import path for dashboard namespace screens while + * the underlying query implementation still lives in the shared hook module. + */ export { useMyNamespaces } from '@/shared/hooks/use-skill-queries' diff --git a/web/src/features/namespace/use-namespace-detail.ts b/web/src/features/namespace/use-namespace-detail.ts index b500e937..dd1caf5e 100644 --- a/web/src/features/namespace/use-namespace-detail.ts +++ b/web/src/features/namespace/use-namespace-detail.ts @@ -1 +1,6 @@ +/** + * Feature-level re-export for namespace detail queries. + * + * The implementation currently lives in the shared query layer. + */ export { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' diff --git a/web/src/features/namespace/use-namespace-members.ts b/web/src/features/namespace/use-namespace-members.ts index ed95254f..533eb1a3 100644 --- a/web/src/features/namespace/use-namespace-members.ts +++ b/web/src/features/namespace/use-namespace-members.ts @@ -1 +1,6 @@ +/** + * Feature-level re-export for namespace membership queries. + * + * The implementation currently lives in the shared query layer. + */ export { useNamespaceMembers } from '@/shared/hooks/use-skill-queries' diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts index 7fa5fd9c..74d88513 100644 --- a/web/src/features/promotion/use-promotion-list.ts +++ b/web/src/features/promotion/use-promotion-list.ts @@ -2,6 +2,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { promotionApi } from '@/api/client' import type { PromotionTask } from '@/api/types' +/** + * Returns the promotion queue for a given status. The hook unwraps the backend + * page object because promotion screens currently consume the item list only. + */ export function usePromotionList(status = 'PENDING') { return useQuery({ queryKey: ['promotions', status], @@ -12,6 +16,9 @@ export function usePromotionList(status = 'PENDING') { }) } +/** + * Loads a single promotion task used by governance detail screens. + */ export function usePromotionDetail(id: number) { return useQuery({ queryKey: ['promotions', id], @@ -20,6 +27,10 @@ export function usePromotionDetail(id: number) { }) } +/** + * Approves a promotion request and refreshes both the promotion list and the + * governance dashboard, which also embeds promotion-derived widgets. + */ export function useApprovePromotion() { const queryClient = useQueryClient() return useMutation({ @@ -31,6 +42,9 @@ export function useApprovePromotion() { }) } +/** + * Rejects a promotion request and keeps dependent governance queries in sync. + */ export function useRejectPromotion() { const queryClient = useQueryClient() return useMutation({ diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index e23a603a..b28fa92b 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -8,6 +8,11 @@ interface UploadZoneProps { disabled?: boolean } +/** + * Provides the publish page dropzone for uploading one zip package at a time. + * The component is intentionally stateless so packaging validation can remain in + * the publish flow that knows the surrounding form and backend constraints. + */ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { const { t } = useTranslation() const onDrop = useCallback( diff --git a/web/src/features/publish/use-publish-skill.ts b/web/src/features/publish/use-publish-skill.ts index ecff691c..83c40b12 100644 --- a/web/src/features/publish/use-publish-skill.ts +++ b/web/src/features/publish/use-publish-skill.ts @@ -1 +1,6 @@ +/** + * Feature-level re-export for the publish mutation. + * + * The implementation currently lives in the shared query layer. + */ export { usePublishSkill } from '@/shared/hooks/use-skill-queries' diff --git a/web/src/features/report/use-skill-reports.ts b/web/src/features/report/use-skill-reports.ts index 2a714ccf..75715f55 100644 --- a/web/src/features/report/use-skill-reports.ts +++ b/web/src/features/report/use-skill-reports.ts @@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { reportApi } from '@/api/client' import type { ReportDisposition } from '@/api/types' +/** + * Loads reported skills for the requested moderation status. + */ export function useSkillReports(status: string) { return useQuery({ queryKey: ['skill-reports', status], @@ -12,12 +15,19 @@ export function useSkillReports(status: string) { }) } +/** + * Submits a report for the current skill detail page. + */ export function useSubmitSkillReport(namespace: string, slug: string) { return useMutation({ mutationFn: (request: { reason: string; details?: string }) => reportApi.submitSkillReport(namespace, slug, request), }) } +/** + * Resolves a report and refreshes both report lists and governance widgets that + * derive unread or pending counts from the same backend sources. + */ export function useResolveSkillReport() { const queryClient = useQueryClient() return useMutation({ @@ -30,6 +40,9 @@ export function useResolveSkillReport() { }) } +/** + * Dismisses a report without taking the heavier resolution path. + */ export function useDismissSkillReport() { const queryClient = useQueryClient() return useMutation({ diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index fee5166f..230b59b5 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -2,18 +2,31 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { reviewApi } from '@/api/client' import type { ReviewTask } from '@/api/types' +/** + * Fetches one review task for governance detail views. + */ async function getReviewDetail(taskId: number): Promise { return reviewApi.get(taskId) } +/** + * Approves the current review task. + */ async function approveReview(taskId: number, comment?: string): Promise { await reviewApi.approve(taskId, comment) } +/** + * Rejects the current review task. A comment is required here because the UI + * treats rejection as an explicit feedback action rather than a silent deny. + */ async function rejectReview(taskId: number, comment: string): Promise { await reviewApi.reject(taskId, comment) } +/** + * Exposes the review detail query keyed by task id. + */ export function useReviewDetail(taskId: number) { return useQuery({ queryKey: ['reviews', taskId], @@ -22,6 +35,10 @@ export function useReviewDetail(taskId: number) { }) } +/** + * Approves a review and refreshes both the review queue and the governance + * dashboard, which reads aggregate review state from separate endpoints. + */ export function useApproveReview(callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) { const queryClient = useQueryClient() @@ -37,6 +54,9 @@ export function useApproveReview(callbacks?: { onSuccess?: () => void; onError?: }) } +/** + * Rejects a review with the same cache invalidation strategy as approval. + */ export function useRejectReview(callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) { const queryClient = useQueryClient() diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts index c0e846fe..e90575be 100644 --- a/web/src/features/review/use-review-list.ts +++ b/web/src/features/review/use-review-list.ts @@ -2,11 +2,17 @@ import { useQuery } from '@tanstack/react-query' import { reviewApi } from '@/api/client' import type { ReviewTask } from '@/api/types' +/** + * Loads review tasks filtered by status and optional namespace scope. + */ async function getReviewList(status: string, namespaceId?: number): Promise { const page = await reviewApi.list({ status, namespaceId }) return page.items } +/** + * Exposes the review list query used by dashboard moderation views. + */ export function useReviewList(status: string, namespaceId?: number) { return useQuery({ queryKey: ['reviews', status, namespaceId], diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 32a3bb49..94be3f25 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -14,6 +14,12 @@ interface SearchBarProps { onSearch?: (query: string) => void } +/** + * Shared search bar used by search-driven pages and landing surfaces. + * + * It supports both controlled and uncontrolled usage so page-level containers can decide whether + * query text should be driven from URL state or local form state. + */ export function SearchBar({ defaultValue = '', value, placeholder, isSearching = false, onChange, onSearch }: SearchBarProps) { const { t } = useTranslation() const [query, setQuery] = useState(defaultValue) diff --git a/web/src/features/skill/code-language.test.ts b/web/src/features/skill/code-language.test.ts index d1ad1186..496a980b 100644 --- a/web/src/features/skill/code-language.test.ts +++ b/web/src/features/skill/code-language.test.ts @@ -3,7 +3,7 @@ import { inferMarkdownCodeLanguage } from './code-language' describe('inferMarkdownCodeLanguage', () => { it('infers python code blocks', () => { - expect(inferMarkdownCodeLanguage('from pypdf import PdfReader\nprint(\"ok\")')).toBe('python') + expect(inferMarkdownCodeLanguage('from pypdf import PdfReader\nprint("ok")')).toBe('python') }) it('infers bash command blocks', () => { @@ -11,7 +11,7 @@ describe('inferMarkdownCodeLanguage', () => { }) it('infers json payloads', () => { - expect(inferMarkdownCodeLanguage('{\n \"name\": \"skillhub\"\n}')).toBe('json') + expect(inferMarkdownCodeLanguage('{\n "name": "skillhub"\n}')).toBe('json') }) it('infers yaml frontmatter style snippets', () => { diff --git a/web/src/features/skill/code-language.ts b/web/src/features/skill/code-language.ts index 58133fc4..42257f79 100644 --- a/web/src/features/skill/code-language.ts +++ b/web/src/features/skill/code-language.ts @@ -1,6 +1,3 @@ -import type { Code, Root } from 'mdast' -import { visit } from 'unist-util-visit' - const BASH_PREFIX_PATTERN = /^(?:\$ |pip3? |python3? -m |python3? |npm |pnpm |yarn |npx |git |make |curl |wget |docker(?:-compose)? |kubectl |helm |cd |ls |cat |cp |mv |rm |mkdir |chmod |export |set |echo )/m const PYTHON_PATTERN = /(?:^|\n)(?:from [\w.]+ import |import [\w.]+|def \w+\(|class \w+|with open\(|print\(|if __name__ == ['"]__main__['"]|for \w+ in |try:|except )/ const SQL_PATTERN = /^(?:select|insert\s+into|update|delete\s+from|create\s+table|alter\s+table|with\s+\w+\s+as)\b/im @@ -8,6 +5,17 @@ const TYPESCRIPT_PATTERN = /(?:^|\n)(?:interface \w+|type \w+\s*=|import type |e const JAVASCRIPT_PATTERN = /(?:^|\n)(?:const |let |var |function \w+\(|export default |export function |module\.exports|import .* from |=>)/ const YAML_LINE_PATTERN = /^(\s*-\s+)?[\w"'./-]+:\s*.+$/m +type MarkdownCodeNode = { + type?: string + lang?: string + value: string +} + +type MarkdownNode = { + type?: string + children?: unknown[] +} + function looksLikeJson(value: string) { try { JSON.parse(value) @@ -56,11 +64,28 @@ export function inferMarkdownCodeLanguage(value: string): string | undefined { } export function remarkInferCodeLanguage() { - return (tree: Root) => { - visit(tree, 'code', (node: Code) => { + return (tree: MarkdownNode) => { + visitCodeNodes(tree, (node) => { if (!node.lang) { node.lang = inferMarkdownCodeLanguage(node.value) } }) } } + +function visitCodeNodes(node: unknown, callback: (node: MarkdownCodeNode) => void) { + if (!node || typeof node !== 'object') { + return + } + + const current = node as MarkdownNode + if (current.type === 'code') { + callback(current as MarkdownCodeNode) + } + + if (Array.isArray(current.children)) { + for (const child of current.children) { + visitCodeNodes(child, callback) + } + } +} diff --git a/web/src/features/skill/file-tree.tsx b/web/src/features/skill/file-tree.tsx index 44742e9d..3cf2f538 100644 --- a/web/src/features/skill/file-tree.tsx +++ b/web/src/features/skill/file-tree.tsx @@ -6,12 +6,21 @@ interface FileTreeProps { onFileClick?: (file: SkillFile) => void } +/** + * Formats file sizes for the package browser without pulling in a heavier + * generic formatting dependency. + */ function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } +/** + * Presents the flat file list returned by the backend package endpoint. + * The UI keeps a simple list rather than reconstructing a nested tree because + * package inspection is currently optimized for quick file selection. + */ export function FileTree({ files, onFileClick }: FileTreeProps) { const { t } = useTranslation() return ( diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 12c18cce..31833567 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -13,6 +13,11 @@ interface MarkdownRendererProps { className?: string } +/** + * Renders markdown from skill packages using a constrained plugin stack. + * Frontmatter is stripped before render because package metadata is surfaced in + * dedicated UI sections and should not appear twice in the document body. + */ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { const containerClassName = [ className, diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 648a4d3b..fa0c2980 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -13,6 +13,9 @@ interface SkillCardProps { highlightStarred?: boolean } +/** + * Reusable card for displaying one skill in lists such as landing, namespace, search, and stars. + */ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) { const { isAuthenticated } = useAuth() const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx index f72ac481..5cc164fd 100644 --- a/web/src/features/social/star-button.tsx +++ b/web/src/features/social/star-button.tsx @@ -10,6 +10,11 @@ interface StarButtonProps { onRequireLogin?: () => void } +/** + * Toggles the current user's star state for a skill while delegating the login + * prompt to the surrounding page. The read query is kept separate so pages can + * decide independently whether star controls should be rendered at all. + */ export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonProps) { const { t } = useTranslation() const { data: starStatus, isLoading } = useStar(skillId) diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts index 7529f0d3..dc57a67b 100644 --- a/web/src/features/social/use-rating.ts +++ b/web/src/features/social/use-rating.ts @@ -6,6 +6,10 @@ interface UserRating { rated: boolean } +/** + * Reads the current user's rating. Unauthenticated users are normalized to an + * unrated state so the rating widget can stay renderable without a hard error. + */ async function getUserRating(skillId: number): Promise { try { return await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/rating`) @@ -17,6 +21,9 @@ async function getUserRating(skillId: number): Promise { } } +/** + * Submits or updates the current user's score for a skill. + */ async function rateSkill(skillId: number, rating: number): Promise { await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/rating`, { method: 'PUT', @@ -27,6 +34,9 @@ async function rateSkill(skillId: number, rating: number): Promise { }) } +/** + * Exposes the user-specific rating query for one skill. + */ export function useUserRating(skillId: number) { return useQuery({ queryKey: ['skills', skillId, 'rating'], @@ -35,6 +45,10 @@ export function useUserRating(skillId: number) { }) } +/** + * Updates the current user's rating and refreshes both the focused rating query + * and broader skill queries that may embed aggregate rating values. + */ export function useRate(skillId: number) { const queryClient = useQueryClient() diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index 64e1c5f8..97f5470e 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -5,6 +5,11 @@ interface StarStatus { starred: boolean } +/** + * Star-state hooks for one skill. + * + * Anonymous users are treated as unstarred instead of surfacing authorization failures into the UI. + */ async function getStarStatus(skillId: number): Promise { try { const starred = await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/star`) @@ -45,6 +50,8 @@ export function useToggleStar(skillId: number) { return useMutation({ mutationFn: (starred: boolean) => toggleStar(skillId, starred), onSuccess: () => { + // Star actions affect both the local button state and starred-skill collections elsewhere in + // the app. queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] }) queryClient.invalidateQueries({ queryKey: ['skills'] }) queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] }) diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 81946fa1..180b2165 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -27,6 +27,10 @@ interface CreateTokenDialogProps { const MAX_TOKEN_NAME_LENGTH = 64 +/** + * Handles API token creation, including duplicate-name checks, expiration + * selection, and the one-time reveal of the raw token value after creation. + */ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) { const { t, i18n } = useTranslation() const [open, setOpen] = useState(false) @@ -114,6 +118,8 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD const minDateTime = toLocalDateTimeInputValue(new Date()) return ( + // Reopening the dialog resets transient creation state because the raw token + // is only meant to be shown once, immediately after a successful create call. { if (nextOpen) { setCreatedToken(null) diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 28b826c6..60de9a1c 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -28,6 +28,12 @@ import { TOKEN_TABLE_ACTIONS_HEAD_CLASS_NAME, TOKEN_TABLE_HEAD_CLASS_NAME } from const PAGE_SIZE = 10 type TokenPage = { items: ApiToken[]; total: number; page: number; size: number } +/** + * Dashboard token-management panel. + * + * It owns token pagination, optimistic deletion, expiration editing, and creation entry points for + * personal API credentials. + */ export function TokenList() { const { t, i18n } = useTranslation() const queryClient = useQueryClient() @@ -71,6 +77,8 @@ export function TokenList() { const deleteMutation = useMutation({ mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId), onMutate: async (tokenId) => { + // Remove the token optimistically so the table feels responsive while the server processes the + // revocation request. await queryClient.cancelQueries({ queryKey: ['tokens'] }) const previousPages = queryClient.getQueriesData({ queryKey: ['tokens'] }) diff --git a/web/src/i18n/config.ts b/web/src/i18n/config.ts index 9cc617f9..9973671f 100644 --- a/web/src/i18n/config.ts +++ b/web/src/i18n/config.ts @@ -4,6 +4,11 @@ import LanguageDetector from 'i18next-browser-languagedetector' import en from './locales/en.json' import zh from './locales/zh.json' +/** + * Initializes i18next for the browser app. Language preference is restored from + * localStorage first so the UI stays stable across reloads before falling back + * to the browser locale. + */ i18n .use(LanguageDetector) .use(initReactI18next) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e09f8a9a..abf37f4c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -16,7 +16,7 @@ "subtitle": "Build powerful AI agents with community-driven skills", "searchPlaceholder": "Search skills...", "exploreSkills": "Explore Skills", - "publishSkill": "Start Building" + "publishSkill": "Publish Skill" }, "features": { "secure": { @@ -528,6 +528,9 @@ "filterUnhideSkill": "Skill Unhidden", "filterUnarchiveSkill": "Skill Restored", "filterYankVersion": "Version Yanked", + "filterRebuildSearchIndex": "Search Index Rebuilt", + "quickFilterSearchRebuild": "Search index rebuilds only", + "clearFilters": "Clear filters", "userIdPlaceholder": "User ID...", "requestIdPlaceholder": "Request ID...", "ipPlaceholder": "IP address...", @@ -560,7 +563,7 @@ "defaultError": "Failed to update profile. Please try again.", "validation": { "length": "Display name must be 2-32 characters.", - "pattern": "Display name can only contain Chinese, English, digits, underscores, and hyphens." + "pattern": "Display name can only contain Chinese, English, digits, spaces, underscores, and hyphens." }, "pendingReview": "Your display name change to \"{{name}}\" is pending review.", "rejected": "Your display name change was rejected.", @@ -652,6 +655,7 @@ "statusArchived": "Archived", "statusHidden": "Hidden", "pendingPreviewBadge": "Pending Preview", + "rejectedBadge": "Review Rejected", "pendingPreviewTitle": "You are previewing a pending version", "pendingPreviewDescription": "This version is only visible to you. Before review approval, you can inspect the README, files, and version information, but you cannot star, rate, report, or download it.", "pendingPreviewInteractionHint": "Stars, ratings, and reports are disabled while this version is pending review.", @@ -733,6 +737,7 @@ "promotionAlreadyPromotedDescription": "This skill has already been promoted to the global namespace.", "promotionErrorTitle": "Failed to submit promotion request", "reportSkill": "Report Skill", + "reportedSkill": "Reported", "reportDialogTitle": "Report skill", "reportDialogDescription": "Provide a reason so administrators can review and act on it quickly.", "reportReasonPlaceholder": "Reason, for example policy violation, misleading content, or infringement", @@ -798,7 +803,17 @@ "activityTitle": "Governance activity", "activitySubtitle": "Recent audit events for review, promotion, report, and lifecycle actions.", "emptyActivity": "No recent governance activity.", - "unknownActor": "Unknown actor" + "unknownActor": "Unknown actor", + "searchMaintenanceTitle": "Search Index Maintenance", + "searchMaintenanceDescription": "Rebuild the full skill search index. This action is available only to super administrators and is intended for full backfills after search rule changes.", + "searchMaintenanceHint": "This may take a while. Avoid triggering it repeatedly.", + "searchRebuildAction": "Rebuild full search index", + "searchRebuildRunning": "Rebuilding...", + "searchRebuildConfirmTitle": "Rebuild the full search index?", + "searchRebuildConfirmDescription": "The system will rebuild search documents for all skills using the current indexing rules. This operation may take some time.", + "searchRebuildSuccessTitle": "Search index rebuild started", + "searchRebuildSuccessDescription": "The system is rebuilding the full search index using the current rules.", + "searchRebuildErrorTitle": "Search index rebuild failed" }, "members": { "title": "Member Management", @@ -967,7 +982,7 @@ "subtitle": "Upload skill package to SkillHub", "reviewNotice": { "title": "Review Notice", - "description": "Submitted skill packages require admin review before publication. Once approved, your skill will be visible to all users." + "description": "Submitted skill packages require admin review before publication." }, "namespace": "Namespace", "selectNamespace": "Select namespace", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index feac7fff..c5ab2755 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -16,7 +16,7 @@ "subtitle": "使用社区驱动的技能构建强大的 AI 代理", "searchPlaceholder": "搜索技能...", "exploreSkills": "探索技能", - "publishSkill": "开始构建" + "publishSkill": "发布技能" }, "features": { "secure": { @@ -528,6 +528,9 @@ "filterUnhideSkill": "恢复隐藏", "filterUnarchiveSkill": "恢复归档", "filterYankVersion": "版本撤回", + "filterRebuildSearchIndex": "重建搜索索引", + "quickFilterSearchRebuild": "仅看搜索索引重建", + "clearFilters": "清空筛选", "userIdPlaceholder": "用户 ID...", "requestIdPlaceholder": "请求 ID...", "ipPlaceholder": "IP 地址...", @@ -560,7 +563,7 @@ "defaultError": "修改失败,请稍后重试。", "validation": { "length": "昵称长度需为 2-32 个字符。", - "pattern": "昵称只能包含中文、英文、数字、下划线和连字符。" + "pattern": "昵称只能包含中文、英文、数字、空格、下划线和连字符。" }, "pendingReview": "你的昵称变更为「{{name}}」正在审核中。", "rejected": "你的昵称变更被拒绝。", @@ -652,6 +655,7 @@ "statusArchived": "已归档", "statusHidden": "已隐藏", "pendingPreviewBadge": "待审核预览", + "rejectedBadge": "审核未通过", "pendingPreviewTitle": "当前正在预览待审核版本", "pendingPreviewDescription": "该版本仅你本人可见。审核通过前,你可以查看 README、文件和版本信息,但不能收藏、评分、举报或下载。", "pendingPreviewInteractionHint": "待审核预览期间不可收藏、评分或举报。", @@ -733,6 +737,7 @@ "promotionAlreadyPromotedDescription": "这个技能已经提升到全局命名空间,无需重复提交。", "promotionErrorTitle": "提交提升申请失败", "reportSkill": "举报技能", + "reportedSkill": "已举报", "reportDialogTitle": "举报技能", "reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。", "reportReasonPlaceholder": "举报原因,例如包含违规内容、恶意误导、侵权等", @@ -798,7 +803,17 @@ "activityTitle": "治理活动", "activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。", "emptyActivity": "暂无治理活动。", - "unknownActor": "未知操作者" + "unknownActor": "未知操作者", + "searchMaintenanceTitle": "搜索索引维护", + "searchMaintenanceDescription": "重新构建全部技能搜索索引。该操作仅超级管理员可执行,适用于搜索规则变更后的全量回填。", + "searchMaintenanceHint": "执行期间可能耗时较长,请避免频繁触发。", + "searchRebuildAction": "重建全部搜索索引", + "searchRebuildRunning": "重建中...", + "searchRebuildConfirmTitle": "确认重建全部搜索索引?", + "searchRebuildConfirmDescription": "系统会按当前搜索规则重建所有技能的搜索文档。该操作可能持续一段时间。", + "searchRebuildSuccessTitle": "已触发搜索索引重建", + "searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。", + "searchRebuildErrorTitle": "搜索索引重建失败" }, "members": { "title": "成员管理", @@ -967,7 +982,7 @@ "subtitle": "上传技能包到 SkillHub", "reviewNotice": { "title": "发布审核说明", - "description": "技能包提交后需要经过管理员审核才能正式发布。审核通过后,您的技能将对所有用户可见。" + "description": "技能包提交后需要经过管理员审核才能正式发布。" }, "namespace": "命名空间", "selectNamespace": "选择命名空间", diff --git a/web/src/main.tsx b/web/src/main.tsx index 6ae2314f..f68f491d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,6 +3,12 @@ import { App } from './app/providers' import './i18n/config' import './index.css' +/** + * Main React entry point. + * + * Runtime configuration is loaded earlier in `bootstrap.ts`; this file only mounts the configured + * application tree. + */ ReactDOM.createRoot(document.getElementById('root')!).render( , ) diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 07c04941..a53e9e6a 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -33,8 +33,13 @@ const ACTION_OPTIONS = [ { value: 'UNHIDE_SKILL', labelKey: 'auditLog.filterUnhideSkill' }, { value: 'UNARCHIVE_SKILL', labelKey: 'auditLog.filterUnarchiveSkill' }, { value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' }, + { value: 'REBUILD_SEARCH_INDEX', labelKey: 'auditLog.filterRebuildSearchIndex' }, ] as const +/** + * Admin audit log page with server-backed filtering. The route owns filter state + * because the query model maps almost one-to-one to the backend search API. + */ export function AuditLogPage() { const { t, i18n } = useTranslation() const [actionFilter, setActionFilter] = useState('') @@ -64,6 +69,24 @@ export function AuditLogPage() { return formatLocalDateTime(dateString, i18n.language) } + const applySearchIndexRebuildFilter = () => { + setActionFilter('REBUILD_SEARCH_INDEX') + setResourceTypeFilter('SEARCH_INDEX') + setPage(0) + } + + const clearFilters = () => { + setActionFilter('') + setUserIdFilter('') + setRequestIdFilter('') + setIpFilter('') + setResourceTypeFilter('') + setResourceIdFilter('') + setStartTimeFilter('') + setEndTimeFilter('') + setPage(0) + } + return (
@@ -72,6 +95,15 @@ export function AuditLogPage() {
+
+ + +
+