From 626e00219e493c5422976f24815089227e614a1f Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Thu, 19 Mar 2026 10:16:40 +0800
Subject: [PATCH 01/22] feat: add user profile update feature with moderation
support (#90)
* feat: add user profile update feature with moderation support
Add ability for users to update their display name with optional machine/human review.
Backend:
- Add profile_change_request table (V15 migration)
- Add UserProfileService with moderation workflow
- Add PATCH /api/v1/user/profile and GET /api/v1/user/profile endpoints
- Add ProfileModerationService interface with NoOp implementation
- Add ProfileModerationProperties for machine/human review toggles
- Update AuthController /me to refresh session when displayName changes
- Add i18n messages for profile validation and responses
Frontend:
- Add /settings/profile page with edit-on-click pattern
- Add profileApi.updateProfile() to client
- Update user menu: add "Profile Settings", make "Security Settings" local-only
- Add i18n translations (en/zh) for profile settings
Testing:
- Add UserProfileControllerTest with 8 test cases
- Add UserProfileServiceTest with 6 test cases
- Add AuthControllerTest case for session refresh on displayName change
* version sql
* merge main
---
.gitignore | 3 +-
.../iflytek/skillhub/SkillhubApplication.java | 3 +
.../config/ProfileModerationProperties.java | 40 +++
.../skillhub/controller/AuthController.java | 16 +-
.../controller/UserProfileController.java | 212 ++++++++++++++++
.../skillhub/dto/PendingChangesResponse.java | 18 ++
.../skillhub/dto/ProfileUpdateStatus.java | 11 +
.../skillhub/dto/UpdateProfileRequest.java | 33 +++
.../skillhub/dto/UpdateProfileResponse.java | 12 +
.../skillhub/dto/UserProfileResponse.java | 19 ++
.../service/NoOpProfileModerationService.java | 25 ++
.../src/main/resources/application.yml | 4 +
.../migration/V27__profile_change_request.sql | 25 ++
.../src/main/resources/messages.properties | 8 +
.../src/main/resources/messages_zh.properties | 8 +
.../controller/AuthControllerTest.java | 30 +++
.../controller/UserProfileControllerTest.java | 228 ++++++++++++++++++
.../domain/user/ModerationDecision.java | 16 ++
.../domain/user/ModerationResult.java | 25 ++
.../domain/user/ProfileChangeRequest.java | 111 +++++++++
.../user/ProfileChangeRequestRepository.java | 22 ++
.../domain/user/ProfileChangeStatus.java | 25 ++
.../domain/user/ProfileModerationConfig.java | 17 ++
.../domain/user/ProfileModerationService.java | 22 ++
.../domain/user/UpdateProfileResult.java | 28 +++
.../domain/user/UserProfileService.java | 165 +++++++++++++
.../domain/user/UserProfileServiceTest.java | 213 ++++++++++++++++
.../ProfileChangeRequestJpaRepository.java | 21 ++
web/src/api/client.ts | 12 +
web/src/app/router.tsx | 12 +
web/src/i18n/locales/en.json | 20 ++
web/src/i18n/locales/zh.json | 20 ++
web/src/pages/settings/profile.tsx | 153 ++++++++++++
web/src/shared/components/user-menu.tsx | 11 +-
34 files changed, 1581 insertions(+), 7 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java
create mode 100644 server/skillhub-app/src/main/resources/db/migration/V27__profile_change_request.sql
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java
create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java
create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java
create mode 100644 web/src/pages/settings/profile.tsx
diff --git a/.gitignore b/.gitignore
index c3d2dc4b..3d73f1ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,9 +68,8 @@ __pycache__/
# Superpowers (AI planning artifacts)
docs/superpowers/
docs/review/
+docs/requirements/
CLAUDE.md
# oh-my-claudecode
.omc
-
-
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 b85d9caa..38f33ec6 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
@@ -1,9 +1,12 @@
package com.iflytek.skillhub;
+import com.iflytek.skillhub.config.ProfileModerationProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
+@EnableConfigurationProperties(ProfileModerationProperties.class)
public class SkillhubApplication {
public static void main(String[] args) {
SpringApplication.run(SkillhubApplication.class, args);
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java
new file mode 100644
index 00000000..f678a8e7
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java
@@ -0,0 +1,40 @@
+package com.iflytek.skillhub.config;
+
+import com.iflytek.skillhub.domain.user.ProfileModerationConfig;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration properties for profile moderation behavior.
+ *
+ *
Controls whether machine review and/or human review are enabled
+ * when users update their profile. Both default to {@code false} (open-source mode).
+ *
+ *
Configuration combinations:
+ *
+ * machine=false, human=false → changes apply immediately (open-source default)
+ * machine=true, human=false → machine review only, pass = immediate effect
+ * machine=false, human=true → skip machine review, enter human review queue
+ * machine=true, human=true → machine review first, then human review queue
+ *
+ *
+ * Implements {@link ProfileModerationConfig} to decouple domain layer from Spring Boot.
+ *
+ * @param machineReview whether to run machine review (e.g. sensitive word detection)
+ * @param humanReview whether to queue changes for human reviewer approval
+ */
+@ConfigurationProperties(prefix = "skillhub.profile.moderation")
+public record ProfileModerationProperties(
+ boolean machineReview,
+ boolean humanReview
+) implements ProfileModerationConfig {
+
+ /** Default constructor — both switches off (open-source mode). */
+ public ProfileModerationProperties() {
+ this(false, false);
+ }
+
+ /** Returns true if any form of moderation is active. */
+ public boolean isAnyModerationEnabled() {
+ return machineReview || humanReview;
+ }
+}
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 688f9fec..76b62a92 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
@@ -84,10 +84,20 @@ public class AuthController extends BaseApiController {
userRoleBindingRepository.findByUserId(principal.userId()).stream()
.map(binding -> binding.getRole().getCode())
.collect(Collectors.toSet()));
- if (!freshRoles.equals(principal.platformRoles())) {
+
+ // Detect stale session: check if roles or profile fields have changed
+ boolean rolesChanged = !freshRoles.equals(principal.platformRoles());
+ boolean displayNameChanged = !user.getDisplayName().equals(principal.displayName());
+ boolean avatarChanged = !java.util.Objects.equals(user.getAvatarUrl(), principal.avatarUrl());
+
+ if (rolesChanged || displayNameChanged || avatarChanged) {
principal = new PlatformPrincipal(
- principal.userId(), principal.displayName(), principal.email(),
- principal.avatarUrl(), principal.oauthProvider(), freshRoles);
+ principal.userId(),
+ user.getDisplayName(), // use DB value (may have been updated via profile)
+ principal.email(),
+ user.getAvatarUrl(), // use DB value
+ principal.oauthProvider(),
+ freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
new file mode 100644
index 00000000..cdbb5122
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
@@ -0,0 +1,212 @@
+package com.iflytek.skillhub.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.skillhub.auth.session.PlatformSessionService;
+import com.iflytek.skillhub.domain.user.ProfileChangeRequest;
+import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository;
+import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
+import com.iflytek.skillhub.domain.user.UpdateProfileResult;
+import com.iflytek.skillhub.domain.user.UserAccount;
+import com.iflytek.skillhub.domain.user.UserAccountRepository;
+import com.iflytek.skillhub.domain.user.UserProfileService;
+import com.iflytek.skillhub.dto.ApiResponse;
+import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.dto.PendingChangesResponse;
+import com.iflytek.skillhub.dto.ProfileUpdateStatus;
+import com.iflytek.skillhub.dto.UpdateProfileRequest;
+import com.iflytek.skillhub.dto.UpdateProfileResponse;
+import com.iflytek.skillhub.dto.UserProfileResponse;
+import com.iflytek.skillhub.exception.UnauthorizedException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * REST controller for user profile management.
+ *
+ *
Provides endpoints for viewing and updating the current user's profile.
+ * All endpoints require authentication — users can only manage their own profile.
+ */
+@RestController
+@RequestMapping("/api/v1/user/profile")
+public class UserProfileController extends BaseApiController {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final TypeReference> MAP_TYPE = new TypeReference<>() {};
+
+ private final UserProfileService userProfileService;
+ private final UserAccountRepository userAccountRepository;
+ private final ProfileChangeRequestRepository changeRequestRepository;
+ private final PlatformSessionService platformSessionService;
+
+ public UserProfileController(ApiResponseFactory responseFactory,
+ UserProfileService userProfileService,
+ UserAccountRepository userAccountRepository,
+ ProfileChangeRequestRepository changeRequestRepository,
+ PlatformSessionService platformSessionService) {
+ super(responseFactory);
+ this.userProfileService = userProfileService;
+ this.userAccountRepository = userAccountRepository;
+ this.changeRequestRepository = changeRequestRepository;
+ this.platformSessionService = platformSessionService;
+ }
+
+ /**
+ * Get the current user's profile, including any pending change request.
+ */
+ @GetMapping
+ public ApiResponse getProfile(
+ @AuthenticationPrincipal PlatformPrincipal principal) {
+ requireAuth(principal);
+
+ UserAccount user = userAccountRepository.findById(principal.userId())
+ .orElseThrow(() -> new UnauthorizedException("error.auth.required"));
+
+ // Look up any PENDING change request for this user
+ PendingChangesResponse pendingChanges = changeRequestRepository
+ .findByUserIdAndStatus(principal.userId(), ProfileChangeStatus.PENDING)
+ .stream()
+ .findFirst()
+ .map(this::toPendingChangesResponse)
+ .orElse(null);
+
+ var response = new UserProfileResponse(
+ user.getDisplayName(),
+ user.getAvatarUrl(),
+ user.getEmail(),
+ pendingChanges
+ );
+ return ok("response.success.read", response);
+ }
+
+ /**
+ * Update the current user's profile fields.
+ *
+ * Depending on moderation configuration, changes may be applied
+ * immediately or queued for human review.
+ */
+ @PatchMapping
+ public ApiResponse updateProfile(
+ @AuthenticationPrincipal PlatformPrincipal principal,
+ Authentication authentication,
+ @Valid @RequestBody UpdateProfileRequest request,
+ HttpServletRequest httpRequest) {
+ requireAuth(principal);
+
+ // Ensure at least one field is provided
+ if (!request.hasChanges()) {
+ throw new IllegalArgumentException("error.profile.noChanges");
+ }
+
+ // Trim displayName if present
+ String displayName = request.displayName() != null
+ ? request.displayName().trim()
+ : null;
+
+ // Build changes map from non-null fields
+ Map changes = new LinkedHashMap<>();
+ if (displayName != null) {
+ changes.put("displayName", displayName);
+ }
+
+ // Delegate to domain service
+ UpdateProfileResult result = userProfileService.updateProfile(
+ principal.userId(),
+ changes,
+ httpRequest.getHeader("X-Request-Id"),
+ resolveClientIp(httpRequest),
+ httpRequest.getHeader("User-Agent")
+ );
+
+ // Refresh session if changes were applied immediately
+ var response = switch (result) {
+ case UpdateProfileResult.Applied() -> {
+ // Rebuild principal with updated displayName and refresh session
+ refreshSession(principal, authentication, changes, httpRequest);
+ yield new UpdateProfileResponse(
+ ProfileUpdateStatus.APPLIED,
+ "response.profile.updated"
+ );
+ }
+ case UpdateProfileResult.PendingReview() -> new UpdateProfileResponse(
+ ProfileUpdateStatus.PENDING_REVIEW,
+ "response.profile.pendingReview"
+ );
+ };
+
+ return ok("response.success.update", response);
+ }
+
+ /**
+ * Refresh the session principal after profile changes are applied.
+ * Mirrors the role-refresh pattern in AuthController.me().
+ */
+ private void refreshSession(PlatformPrincipal principal,
+ Authentication authentication,
+ Map changes,
+ HttpServletRequest request) {
+ String newDisplayName = changes.getOrDefault("displayName", principal.displayName());
+ String newAvatarUrl = changes.getOrDefault("avatarUrl", principal.avatarUrl());
+
+ var updatedPrincipal = new PlatformPrincipal(
+ principal.userId(),
+ newDisplayName,
+ principal.email(),
+ newAvatarUrl,
+ principal.oauthProvider(),
+ principal.platformRoles()
+ );
+ platformSessionService.attachToAuthenticatedSession(
+ updatedPrincipal, authentication, request, false);
+ }
+
+ /**
+ * Convert a ProfileChangeRequest entity to the pending changes response DTO.
+ */
+ private PendingChangesResponse toPendingChangesResponse(ProfileChangeRequest request) {
+ try {
+ Map changes = MAPPER.readValue(request.getChanges(), MAP_TYPE);
+ return new PendingChangesResponse(
+ request.getStatus().name(),
+ changes,
+ request.getCreatedAt()
+ );
+ } catch (Exception e) {
+ // Malformed JSON in DB — return null rather than breaking the GET endpoint
+ return null;
+ }
+ }
+
+ /** Resolve client IP from proxy headers or direct connection. */
+ private String resolveClientIp(HttpServletRequest request) {
+ String ip = request.getHeader("X-Forwarded-For");
+ if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("X-Real-IP");
+ }
+ if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getRemoteAddr();
+ }
+ if (ip != null && ip.contains(",")) {
+ ip = ip.split(",")[0].trim();
+ }
+ return ip;
+ }
+
+ /** Guard — throw 401 if principal is missing. */
+ private void requireAuth(PlatformPrincipal principal) {
+ if (principal == null) {
+ throw new UnauthorizedException("error.auth.required");
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java
new file mode 100644
index 00000000..448bd8e6
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java
@@ -0,0 +1,18 @@
+package com.iflytek.skillhub.dto;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Pending profile changes awaiting human review.
+ * Null when no PENDING request exists for the user.
+ *
+ * @param status always "PENDING" when present
+ * @param changes map of field name → requested new value
+ * @param createdAt when the change request was submitted
+ */
+public record PendingChangesResponse(
+ String status,
+ Map changes,
+ Instant createdAt
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java
new file mode 100644
index 00000000..f4ff47a2
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.dto;
+
+/**
+ * Status of a profile update operation, returned to the frontend.
+ */
+public enum ProfileUpdateStatus {
+ /** Changes were applied immediately to user_account. */
+ APPLIED,
+ /** Changes are queued for human review (not yet applied). */
+ PENDING_REVIEW
+}
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
new file mode 100644
index 00000000..3f264b02
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java
@@ -0,0 +1,33 @@
+package com.iflytek.skillhub.dto;
+
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * Request DTO for updating user profile fields.
+ *
+ * All fields are optional — the caller supplies only the fields they want to change.
+ * At least one non-null field must be present (validated in the controller).
+ *
+ *
Validation rules for displayName:
+ *
+ * Length: 2–32 characters (after trim)
+ * Allowed characters: Chinese, English, digits, underscore, hyphen
+ *
+ *
+ * @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_-]+$",
+ message = "error.profile.displayName.pattern")
+ String displayName
+) {
+ /**
+ * Returns true if at least one field is provided.
+ * Future fields (avatarUrl, etc.) should be added to this check.
+ */
+ public boolean hasChanges() {
+ return displayName != null;
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java
new file mode 100644
index 00000000..b8d5005f
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java
@@ -0,0 +1,12 @@
+package com.iflytek.skillhub.dto;
+
+/**
+ * Response DTO for profile update operations.
+ *
+ * @param status whether the change was applied immediately or queued for review
+ * @param message human-readable status message (i18n key resolved by frontend)
+ */
+public record UpdateProfileResponse(
+ ProfileUpdateStatus status,
+ String message
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java
new file mode 100644
index 00000000..8f8f53f8
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java
@@ -0,0 +1,19 @@
+package com.iflytek.skillhub.dto;
+
+/**
+ * Response DTO for GET /api/v1/user/profile.
+ *
+ * Returns the current (approved) profile values plus any pending
+ * change request awaiting review.
+ *
+ * @param displayName current approved display name
+ * @param avatarUrl current approved avatar URL
+ * @param email user email (read-only, not editable via profile)
+ * @param pendingChanges pending change request details, or null if none
+ */
+public record UserProfileResponse(
+ String displayName,
+ String avatarUrl,
+ String email,
+ PendingChangesResponse pendingChanges
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java
new file mode 100644
index 00000000..d6023e4e
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java
@@ -0,0 +1,25 @@
+package com.iflytek.skillhub.service;
+
+import com.iflytek.skillhub.domain.user.ModerationResult;
+import com.iflytek.skillhub.domain.user.ProfileModerationService;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * Default (no-op) profile moderation service for open-source deployments.
+ *
+ *
Always returns {@link ModerationResult#approved()}, meaning profile
+ * changes take effect immediately without any review.
+ *
+ *
SaaS deployments can override by providing their own
+ * {@link ProfileModerationService} bean annotated with {@code @Primary}.
+ */
+@Service
+public class NoOpProfileModerationService implements ProfileModerationService {
+
+ @Override
+ public ModerationResult moderate(String userId, Map changes) {
+ return ModerationResult.approved();
+ }
+}
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 03c6fcaa..2b32c5e4 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -110,6 +110,10 @@ skillhub:
max-single-file-size: 10485760 # 10MB
max-package-size: 104857600 # 100MB
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: false # Enable machine review (e.g. sensitive word detection)
+ human-review: 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/db/migration/V27__profile_change_request.sql b/server/skillhub-app/src/main/resources/db/migration/V27__profile_change_request.sql
new file mode 100644
index 00000000..f1e395c9
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/db/migration/V27__profile_change_request.sql
@@ -0,0 +1,25 @@
+-- Profile change request table.
+-- Tracks user-initiated profile modifications (display name, avatar, etc.)
+-- with optional machine and human review workflow.
+-- The 'changes' and 'old_values' columns use JSONB to support batch field updates
+-- in a single request, e.g. {"displayName": "new name", "avatarUrl": "https://..."}
+
+CREATE TABLE profile_change_request (
+ id BIGSERIAL PRIMARY KEY,
+ user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
+ changes JSONB NOT NULL, -- requested field changes (key = field name, value = new value)
+ old_values JSONB, -- snapshot of previous values before this change
+ status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
+ -- PENDING | MACHINE_REJECTED | APPROVED | REJECTED | CANCELLED
+ machine_result VARCHAR(32), -- PASS | FAIL | SKIPPED
+ machine_reason TEXT, -- rejection reason from machine review
+ reviewer_id VARCHAR(128) REFERENCES user_account(id), -- human reviewer who acted on this request
+ review_comment TEXT, -- human reviewer's comment
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ reviewed_at TIMESTAMP -- timestamp when human review was completed
+);
+
+CREATE INDEX idx_pcr_user_id ON profile_change_request(user_id);
+CREATE INDEX idx_pcr_status ON profile_change_request(status);
+CREATE INDEX idx_pcr_created ON profile_change_request(created_at DESC);
+CREATE INDEX idx_pcr_changes ON profile_change_request USING GIN (changes);
diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties
index 6d099224..7467829e 100644
--- a/server/skillhub-app/src/main/resources/messages.properties
+++ b/server/skillhub-app/src/main/resources/messages.properties
@@ -124,3 +124,11 @@ error.admin.user.status.invalid=Invalid user status: {0}
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace
+
+# 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.noChanges=At least one field must be provided
+response.profile.updated=Profile updated successfully
+response.profile.pendingReview=Profile changes submitted for review
+
diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties
index b98d4ad5..5075d0ae 100644
--- a/server/skillhub-app/src/main/resources/messages_zh.properties
+++ b/server/skillhub-app/src/main/resources/messages_zh.properties
@@ -124,3 +124,11 @@ error.admin.user.status.invalid=无效的用户状态:{0}
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能
+
+# 用户资料修改
+error.profile.displayName.length=昵称长度需在 2-32 个字符之间
+error.profile.displayName.pattern=昵称仅允许中文、英文、数字、下划线和连字符
+error.profile.noChanges=至少需要提供一个修改字段
+response.profile.updated=资料已更新
+response.profile.pendingReview=资料变更已提交审核
+
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java
index 29184539..47e77a31 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java
@@ -107,6 +107,36 @@ class AuthControllerTest {
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
+ // ===== AC-P-002: Session refresh when displayName changes =====
+
+ @Test
+ void meShouldRefreshSessionWhenDisplayNameChanges() throws Exception {
+ given(namespaceMemberRepository.findByUserId("user-42")).willReturn(List.of());
+ var user = new UserAccount("user-42", "UpdatedName", "tester@example.com", "https://example.com/avatar.png");
+ given(userAccountRepository.findById("user-42")).willReturn(java.util.Optional.of(user));
+ given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of());
+
+ PlatformPrincipal principal = new PlatformPrincipal(
+ "user-42",
+ "OldName", // stale displayName in session
+ "tester@example.com",
+ "https://example.com/avatar.png",
+ "github",
+ Set.of("USER")
+ );
+
+ var auth = new UsernamePasswordAuthenticationToken(
+ principal,
+ null,
+ List.of(new SimpleGrantedAuthority("ROLE_USER"))
+ );
+
+ mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.displayName").value("UpdatedName")); // should return DB value
+ }
+
@Test
void providersShouldExposeGithubLoginEntry() throws Exception {
mockMvc.perform(get("/api/v1/auth/providers"))
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
new file mode 100644
index 00000000..cf4202af
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java
@@ -0,0 +1,228 @@
+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.ProfileChangeRequestRepository;
+import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
+import com.iflytek.skillhub.domain.user.UserAccount;
+import com.iflytek.skillhub.domain.user.UserAccountRepository;
+import com.iflytek.skillhub.security.AuthFailureThrottleService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.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.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for {@link UserProfileController}.
+ * Uses MockMvc with Spring Security context to test the full HTTP flow.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.security.oauth2.client.registration.github.client-name=GitHub",
+ "spring.security.oauth2.client.registration.gitee.client-id=placeholder",
+ "spring.security.oauth2.client.registration.gitee.client-secret=placeholder",
+ "spring.security.oauth2.client.registration.gitee.provider=gitee",
+ "spring.security.oauth2.client.registration.gitee.authorization-grant-type=authorization_code",
+ "spring.security.oauth2.client.registration.gitee.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
+ "spring.security.oauth2.client.registration.gitee.scope=user_info",
+ "spring.security.oauth2.client.registration.gitee.client-name=Gitee",
+ "spring.security.oauth2.client.provider.gitee.authorization-uri=https://gitee.com/oauth/authorize",
+ "spring.security.oauth2.client.provider.gitee.token-uri=https://gitee.com/oauth/token",
+ "spring.security.oauth2.client.provider.gitee.user-info-uri=https://gitee.com/api/v5/user",
+ "spring.security.oauth2.client.provider.gitee.user-name-attribute=id",
+ "skillhub.profile.moderation.machine-review=false",
+ "skillhub.profile.moderation.human-review=false"
+})
+class UserProfileControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private NamespaceMemberRepository namespaceMemberRepository;
+
+ @MockBean
+ private AuthFailureThrottleService authFailureThrottleService;
+
+ @MockBean
+ private UserAccountRepository userAccountRepository;
+
+ @MockBean
+ private UserRoleBindingRepository userRoleBindingRepository;
+
+ @MockBean
+ private ProfileChangeRequestRepository changeRequestRepository;
+
+ @MockBean
+ private PlatformSessionService platformSessionService;
+
+ // -- Helper --
+
+ private PlatformPrincipal testPrincipal() {
+ return new PlatformPrincipal(
+ "user-1", "OldName", "user@example.com",
+ "https://example.com/avatar.png", "github", Set.of("USER"));
+ }
+
+ private UsernamePasswordAuthenticationToken testAuth(PlatformPrincipal principal) {
+ return new UsernamePasswordAuthenticationToken(
+ principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ }
+
+ // ===== AC-S-001: Unauthorized access to PATCH =====
+
+ @Test
+ void updateProfile_unauthenticated_shouldReturn401() throws Exception {
+ mockMvc.perform(patch("/api/v1/user/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"displayName\":\"NewName\"}"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ // ===== AC-S-002: Unauthorized access to GET =====
+
+ @Test
+ void getProfile_unauthenticated_shouldReturn401() throws Exception {
+ mockMvc.perform(get("/api/v1/user/profile"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ // ===== AC-P-001: Successful update =====
+
+ @Test
+ void updateProfile_validRequest_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\":\"NewName\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("APPLIED"));
+ }
+
+ // ===== AC-E-001: Display name too short =====
+
+ @Test
+ void updateProfile_displayNameTooShort_shouldReturn400() throws Exception {
+ var principal = testPrincipal();
+
+ mockMvc.perform(patch("/api/v1/user/profile")
+ .with(authentication(testAuth(principal)))
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"displayName\":\"A\"}"))
+ .andExpect(status().isBadRequest());
+ }
+
+ // ===== AC-E-002: Display name too long =====
+
+ @Test
+ void updateProfile_displayNameTooLong_shouldReturn400() throws Exception {
+ var principal = testPrincipal();
+ String longName = "a".repeat(33);
+
+ mockMvc.perform(patch("/api/v1/user/profile")
+ .with(authentication(testAuth(principal)))
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"displayName\":\"" + longName + "\"}"))
+ .andExpect(status().isBadRequest());
+ }
+
+ // ===== AC-E-003: Invalid characters =====
+
+ @Test
+ void updateProfile_invalidCharacters_shouldReturn400() throws Exception {
+ var principal = testPrincipal();
+
+ mockMvc.perform(patch("/api/v1/user/profile")
+ .with(authentication(testAuth(principal)))
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"displayName\":\"test@user!\"}"))
+ .andExpect(status().isBadRequest());
+ }
+
+ // ===== AC-E-006: Empty request body =====
+
+ @Test
+ void updateProfile_emptyRequest_shouldReturn400() 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));
+
+ mockMvc.perform(patch("/api/v1/user/profile")
+ .with(authentication(testAuth(principal)))
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{}"))
+ .andExpect(status().isBadRequest());
+ }
+
+ // ===== AC-P-006: GET profile with no pending changes =====
+
+ @Test
+ void getProfile_noPendingChanges_shouldReturnCurrentValues() throws Exception {
+ var principal = testPrincipal();
+ var user = new UserAccount("user-1", "CurrentName", "user@example.com", "https://example.com/avatar.png");
+
+ given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user));
+ given(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
+ .willReturn(List.of());
+
+ mockMvc.perform(get("/api/v1/user/profile")
+ .with(authentication(testAuth(principal))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.displayName").value("CurrentName"))
+ .andExpect(jsonPath("$.data.email").value("user@example.com"))
+ .andExpect(jsonPath("$.data.pendingChanges").isEmpty());
+ }
+
+ // ===== AC-S-003: XSS attempt =====
+
+ @Test
+ void updateProfile_xssAttempt_shouldReturn400() throws Exception {
+ var principal = testPrincipal();
+
+ mockMvc.perform(patch("/api/v1/user/profile")
+ .with(authentication(testAuth(principal)))
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"displayName\":\"\"}"))
+ .andExpect(status().isBadRequest());
+ }
+}
+
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java
new file mode 100644
index 00000000..3b933c4f
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java
@@ -0,0 +1,16 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Outcome of a profile moderation check.
+ *
+ * Used as a sealed hierarchy so callers must handle all cases
+ * via pattern matching (Java 21 switch expressions).
+ */
+public enum ModerationDecision {
+ /** Change is approved — apply immediately. */
+ APPROVED,
+ /** Change is rejected — return error to user. */
+ REJECTED,
+ /** Change needs human review — queue for reviewer. */
+ NEEDS_REVIEW
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java
new file mode 100644
index 00000000..e777f46a
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java
@@ -0,0 +1,25 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Result of a profile moderation check, combining the decision with an optional reason.
+ *
+ * @param decision the moderation outcome
+ * @param reason human-readable reason (populated on REJECTED; null otherwise)
+ */
+public record ModerationResult(ModerationDecision decision, String reason) {
+
+ /** Convenience factory — change approved, no reason needed. */
+ public static ModerationResult approved() {
+ return new ModerationResult(ModerationDecision.APPROVED, null);
+ }
+
+ /** Convenience factory — change rejected with a reason. */
+ public static ModerationResult rejected(String reason) {
+ return new ModerationResult(ModerationDecision.REJECTED, reason);
+ }
+
+ /** Convenience factory — change needs human review. */
+ public static ModerationResult needsReview() {
+ return new ModerationResult(ModerationDecision.NEEDS_REVIEW, null);
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java
new file mode 100644
index 00000000..67ba2530
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java
@@ -0,0 +1,111 @@
+package com.iflytek.skillhub.domain.user;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+import java.time.Instant;
+
+/**
+ * Represents a user-initiated profile change request.
+ *
+ *
Each request captures a batch of field changes as JSONB (e.g. displayName, avatarUrl),
+ * along with the previous values for audit and rollback purposes.
+ *
+ *
Depending on the moderation configuration, a request may be:
+ *
+ * Immediately approved (no moderation)
+ * Rejected by machine review
+ * Queued for human review (PENDING)
+ *
+ *
+ * @see ProfileChangeStatus for the full lifecycle
+ */
+@Entity
+@Table(name = "profile_change_request")
+public class ProfileChangeRequest {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /** The user who initiated this change request. */
+ @Column(name = "user_id", nullable = false, length = 128)
+ private String userId;
+
+ /** Requested changes as JSON, e.g. {"displayName": "new name"}. */
+ @Column(nullable = false)
+ @JdbcTypeCode(SqlTypes.JSON)
+ private String changes;
+
+ /** Snapshot of previous values before this change, e.g. {"displayName": "old name"}. */
+ @Column(name = "old_values")
+ @JdbcTypeCode(SqlTypes.JSON)
+ private String oldValues;
+
+ /** Current status in the review lifecycle. */
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 32)
+ private ProfileChangeStatus status = ProfileChangeStatus.PENDING;
+
+ /** Machine review outcome: PASS, FAIL, or SKIPPED. */
+ @Column(name = "machine_result", length = 32)
+ private String machineResult;
+
+ /** Reason provided by machine review when rejected. */
+ @Column(name = "machine_reason")
+ private String machineReason;
+
+ /** User ID of the human reviewer who acted on this request. */
+ @Column(name = "reviewer_id", length = 128)
+ private String reviewerId;
+
+ /** Comment left by the human reviewer. */
+ @Column(name = "review_comment")
+ private String reviewComment;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private Instant createdAt;
+
+ /** Timestamp when human review was completed. */
+ @Column(name = "reviewed_at")
+ private Instant reviewedAt;
+
+ protected ProfileChangeRequest() {}
+
+ public ProfileChangeRequest(String userId, String changes, String oldValues,
+ ProfileChangeStatus status, String machineResult,
+ String machineReason) {
+ this.userId = userId;
+ this.changes = changes;
+ this.oldValues = oldValues;
+ this.status = status;
+ this.machineResult = machineResult;
+ this.machineReason = machineReason;
+ }
+
+ @PrePersist
+ void prePersist() {
+ this.createdAt = Instant.now();
+ }
+
+ // -- Getters --
+
+ public Long getId() { return id; }
+ public String getUserId() { return userId; }
+ public String getChanges() { return changes; }
+ public String getOldValues() { return oldValues; }
+ public ProfileChangeStatus getStatus() { return status; }
+ public String getMachineResult() { return machineResult; }
+ public String getMachineReason() { return machineReason; }
+ public String getReviewerId() { return reviewerId; }
+ public String getReviewComment() { return reviewComment; }
+ public Instant getCreatedAt() { return createdAt; }
+ public Instant getReviewedAt() { return reviewedAt; }
+
+ // -- Setters (only for mutable fields) --
+
+ public void setStatus(ProfileChangeStatus status) { this.status = status; }
+ public void setReviewerId(String reviewerId) { this.reviewerId = reviewerId; }
+ public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; }
+ public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
+}
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
new file mode 100644
index 00000000..7e4d10dd
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java
@@ -0,0 +1,22 @@
+package com.iflytek.skillhub.domain.user;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Repository for {@link ProfileChangeRequest} entities.
+ * Implementations are provided by the infra layer (JPA).
+ */
+public interface ProfileChangeRequestRepository {
+
+ ProfileChangeRequest save(ProfileChangeRequest request);
+
+ Optional findById(Long id);
+
+ /**
+ * Find all requests for a given user with a specific status.
+ * Primarily used to locate PENDING requests when a user submits
+ * a new change (so the old PENDING ones can be cancelled).
+ */
+ List findByUserIdAndStatus(String userId, ProfileChangeStatus status);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java
new file mode 100644
index 00000000..fb59f202
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java
@@ -0,0 +1,25 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Status of a profile change request throughout its lifecycle.
+ *
+ * State transitions:
+ *
+ * PENDING ──→ APPROVED (human reviewer approves)
+ * PENDING ──→ REJECTED (human reviewer rejects)
+ * PENDING ──→ CANCELLED (user submits a new request, replacing this one)
+ * (direct) ──→ MACHINE_REJECTED (machine review rejects before entering queue)
+ *
+ */
+public enum ProfileChangeStatus {
+ /** Awaiting human review. */
+ PENDING,
+ /** Rejected by machine review (e.g. sensitive word detection). */
+ MACHINE_REJECTED,
+ /** Approved and applied to user_account. */
+ APPROVED,
+ /** Rejected by human reviewer. */
+ REJECTED,
+ /** Superseded by a newer request from the same user. */
+ CANCELLED
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java
new file mode 100644
index 00000000..4b3b856d
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java
@@ -0,0 +1,17 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Domain-level abstraction for profile moderation configuration.
+ *
+ * Decouples the domain service from Spring Boot's
+ * {@code @ConfigurationProperties}. The app layer provides
+ * the concrete implementation backed by application.yml.
+ */
+public interface ProfileModerationConfig {
+
+ /** Whether machine review (e.g. sensitive word detection) is enabled. */
+ boolean machineReview();
+
+ /** Whether human review queue is enabled. */
+ boolean humanReview();
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java
new file mode 100644
index 00000000..ca5c720f
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java
@@ -0,0 +1,22 @@
+package com.iflytek.skillhub.domain.user;
+
+import java.util.Map;
+
+/**
+ * Pluggable moderation service for user profile changes.
+ *
+ *
Implementations are provided at the application layer and injected
+ * into domain services. The open-source default is a no-op that always
+ * approves; SaaS deployments can supply a machine-review implementation.
+ */
+public interface ProfileModerationService {
+
+ /**
+ * Evaluate proposed profile changes against moderation rules.
+ *
+ * @param userId the user requesting the change
+ * @param changes map of field name → new value (e.g. "displayName" → "new name")
+ * @return moderation result indicating whether to approve, reject, or queue for review
+ */
+ ModerationResult moderate(String userId, Map changes);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java
new file mode 100644
index 00000000..c3bfc4af
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java
@@ -0,0 +1,28 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Result of a profile update operation.
+ *
+ * Uses a sealed interface with record implementations (Java 17+)
+ * to enable exhaustive pattern matching in callers.
+ *
+ * @see UserProfileService#updateProfile
+ */
+public sealed interface UpdateProfileResult {
+
+ /** Changes were applied immediately to user_account. */
+ record Applied() implements UpdateProfileResult {}
+
+ /** Changes are queued for human review (not yet applied). */
+ record PendingReview() implements UpdateProfileResult {}
+
+ /** Convenience factory for the applied case. */
+ static UpdateProfileResult applied() {
+ return new Applied();
+ }
+
+ /** Convenience factory for the pending-review case. */
+ static UpdateProfileResult pendingReview() {
+ return new PendingReview();
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java
new file mode 100644
index 00000000..d6259c4b
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java
@@ -0,0 +1,165 @@
+package com.iflytek.skillhub.domain.user;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.iflytek.skillhub.domain.audit.AuditLogService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Core service for user profile updates.
+ *
+ *
Orchestrates the full update flow: validation → machine review →
+ * human review (if configured) → apply changes → audit logging.
+ *
+ *
The moderation behavior is driven by {@link ProfileModerationConfig}:
+ * when both switches are off, changes apply immediately (open-source default).
+ */
+@Service
+public class UserProfileService {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final UserAccountRepository userAccountRepository;
+ private final ProfileChangeRequestRepository changeRequestRepository;
+ private final ProfileModerationService moderationService;
+ private final ProfileModerationConfig moderationConfig;
+ private final AuditLogService auditLogService;
+
+ public UserProfileService(UserAccountRepository userAccountRepository,
+ ProfileChangeRequestRepository changeRequestRepository,
+ ProfileModerationService moderationService,
+ ProfileModerationConfig moderationConfig,
+ AuditLogService auditLogService) {
+ this.userAccountRepository = userAccountRepository;
+ this.changeRequestRepository = changeRequestRepository;
+ this.moderationService = moderationService;
+ this.moderationConfig = moderationConfig;
+ this.auditLogService = auditLogService;
+ }
+
+ /**
+ * Update user profile fields (e.g. displayName, avatarUrl).
+ *
+ *
Depending on moderation config, this may:
+ *
+ * Apply changes immediately (no moderation)
+ * Reject via machine review
+ * Queue for human review (PENDING)
+ *
+ *
+ * @param userId the user making the change
+ * @param changes map of field name → new value
+ * @param requestId HTTP request ID for audit trail
+ * @param clientIp client IP address
+ * @param userAgent client user agent
+ * @return result indicating whether changes were applied or queued
+ */
+ @Transactional
+ public UpdateProfileResult updateProfile(String userId,
+ Map changes,
+ String requestId,
+ String clientIp,
+ String userAgent) {
+ UserAccount user = userAccountRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
+
+ // 1. Build snapshot of old values for audit and rollback
+ Map oldValues = buildOldValues(user, changes);
+
+ // 2. Machine review (if enabled)
+ if (moderationConfig.machineReview()) {
+ ModerationResult machineResult = moderationService.moderate(userId, changes);
+ if (machineResult.decision() == ModerationDecision.REJECTED) {
+ saveChangeRequest(userId, changes, oldValues, ProfileChangeStatus.MACHINE_REJECTED,
+ "FAIL", machineResult.reason());
+ throw new IllegalArgumentException(machineResult.reason());
+ }
+ }
+
+ // 3. Human review (if enabled)
+ if (moderationConfig.humanReview()) {
+ cancelPendingRequests(userId); // Replace any existing PENDING request
+ saveChangeRequest(userId, changes, oldValues, ProfileChangeStatus.PENDING,
+ moderationConfig.machineReview() ? "PASS" : "SKIPPED", null);
+ return UpdateProfileResult.pendingReview();
+ }
+
+ // 4. No moderation — apply changes immediately
+ applyChanges(user, changes);
+ saveChangeRequest(userId, changes, oldValues, ProfileChangeStatus.APPROVED,
+ moderationConfig.machineReview() ? "PASS" : "SKIPPED", null);
+
+ // 5. Audit log
+ auditLogService.record(userId, "PROFILE_UPDATE", "USER", null,
+ requestId, clientIp, userAgent,
+ toJson(Map.of("changes", changes, "oldValues", oldValues)));
+
+ return UpdateProfileResult.applied();
+ }
+
+ /**
+ * Apply approved changes to the user account.
+ * Extensible for future fields (avatarUrl, etc.).
+ */
+ private void applyChanges(UserAccount user, Map changes) {
+ if (changes.containsKey("displayName")) {
+ user.setDisplayName(changes.get("displayName"));
+ }
+ // Future: avatarUrl, etc.
+ userAccountRepository.save(user);
+ }
+
+ /**
+ * Cancel any existing PENDING requests for this user.
+ * Called when a user submits a new change, superseding the old one.
+ */
+ private void cancelPendingRequests(String userId) {
+ changeRequestRepository.findByUserIdAndStatus(userId, ProfileChangeStatus.PENDING)
+ .forEach(req -> {
+ req.setStatus(ProfileChangeStatus.CANCELLED);
+ changeRequestRepository.save(req);
+ });
+ }
+
+ /**
+ * Build a snapshot of the current values for fields being changed.
+ * Used for audit trail and potential rollback.
+ */
+ private Map buildOldValues(UserAccount user, Map changes) {
+ Map oldValues = new LinkedHashMap<>();
+ if (changes.containsKey("displayName")) {
+ oldValues.put("displayName", user.getDisplayName());
+ }
+ // Future: avatarUrl, etc.
+ return oldValues;
+ }
+
+ /**
+ * Persist a change request record for audit and review purposes.
+ */
+ private void saveChangeRequest(String userId, Map changes,
+ Map oldValues, ProfileChangeStatus status,
+ String machineResult, String machineReason) {
+ ProfileChangeRequest request = new ProfileChangeRequest(
+ userId,
+ toJson(changes),
+ toJson(oldValues),
+ status,
+ machineResult,
+ machineReason
+ );
+ changeRequestRepository.save(request);
+ }
+
+ private String toJson(Object obj) {
+ try {
+ return MAPPER.writeValueAsString(obj);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize to JSON", e);
+ }
+ }
+}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java
new file mode 100644
index 00000000..2990f86c
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java
@@ -0,0 +1,213 @@
+package com.iflytek.skillhub.domain.user;
+
+import com.iflytek.skillhub.domain.audit.AuditLogService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for {@link UserProfileService}.
+ * Covers the core update flow under different moderation configurations.
+ */
+@ExtendWith(MockitoExtension.class)
+class UserProfileServiceTest {
+
+ @Mock
+ private UserAccountRepository userAccountRepository;
+
+ @Mock
+ private ProfileChangeRequestRepository changeRequestRepository;
+
+ @Mock
+ private ProfileModerationService moderationService;
+
+ @Mock
+ private ProfileModerationConfig moderationConfig;
+
+ @Mock
+ private AuditLogService auditLogService;
+
+ @InjectMocks
+ private UserProfileService userProfileService;
+
+ // -- Helper --
+
+ private UserAccount testUser() {
+ return new UserAccount("user-1", "OldName", "user@example.com", "https://example.com/avatar.png");
+ }
+
+ private Map displayNameChange(String newName) {
+ return Map.of("displayName", newName);
+ }
+
+ // ===== AC-P-001: Successful update with no moderation =====
+
+ @Test
+ void updateProfile_noModeration_shouldApplyImmediately() {
+ var user = testUser();
+ when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
+ when(moderationConfig.machineReview()).thenReturn(false);
+ when(moderationConfig.humanReview()).thenReturn(false);
+
+ var result = userProfileService.updateProfile(
+ "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
+
+ // Should return Applied
+ assertInstanceOf(UpdateProfileResult.Applied.class, result);
+
+ // user_account should be updated
+ assertEquals("NewName", user.getDisplayName());
+ verify(userAccountRepository).save(user);
+
+ // Change request should be saved as APPROVED
+ var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
+ verify(changeRequestRepository).save(captor.capture());
+ assertEquals(ProfileChangeStatus.APPROVED, captor.getValue().getStatus());
+
+ // Audit log should be recorded
+ verify(auditLogService).record(eq("user-1"), eq("PROFILE_UPDATE"),
+ eq("USER"), isNull(), eq("req-1"), eq("127.0.0.1"), eq("TestAgent"), any());
+ }
+
+ // ===== AC-P-003: Same value (idempotent) =====
+
+ @Test
+ void updateProfile_sameValue_shouldSucceed() {
+ var user = testUser();
+ when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
+ when(moderationConfig.machineReview()).thenReturn(false);
+ when(moderationConfig.humanReview()).thenReturn(false);
+
+ var result = userProfileService.updateProfile(
+ "user-1", displayNameChange("OldName"), "req-1", "127.0.0.1", "TestAgent");
+
+ assertInstanceOf(UpdateProfileResult.Applied.class, result);
+ assertEquals("OldName", user.getDisplayName());
+ }
+
+ // ===== AC-P-004: Human review enabled — creates PENDING request =====
+
+ @Test
+ void updateProfile_humanReviewEnabled_shouldCreatePendingRequest() {
+ var user = testUser();
+ when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
+ when(moderationConfig.machineReview()).thenReturn(false);
+ when(moderationConfig.humanReview()).thenReturn(true);
+ when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
+ .thenReturn(List.of());
+
+ var result = userProfileService.updateProfile(
+ "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
+
+ // Should return PendingReview
+ assertInstanceOf(UpdateProfileResult.PendingReview.class, result);
+
+ // user_account should NOT be updated
+ assertEquals("OldName", user.getDisplayName());
+ verify(userAccountRepository, never()).save(any());
+
+ // Change request should be saved as PENDING
+ var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
+ verify(changeRequestRepository).save(captor.capture());
+ assertEquals(ProfileChangeStatus.PENDING, captor.getValue().getStatus());
+ assertEquals("SKIPPED", captor.getValue().getMachineResult());
+
+ // No audit log for pending requests
+ verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
+ }
+
+ // ===== AC-P-005: Overwrite existing PENDING request =====
+
+ @Test
+ void updateProfile_existingPending_shouldCancelOldAndCreateNew() {
+ var user = testUser();
+ var oldRequest = new ProfileChangeRequest("user-1", "{\"displayName\":\"PendingName\"}",
+ "{\"displayName\":\"OldName\"}", ProfileChangeStatus.PENDING, "SKIPPED", null);
+
+ when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
+ when(moderationConfig.machineReview()).thenReturn(false);
+ when(moderationConfig.humanReview()).thenReturn(true);
+ when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
+ .thenReturn(List.of(oldRequest));
+
+ userProfileService.updateProfile(
+ "user-1", displayNameChange("NewerName"), "req-1", "127.0.0.1", "TestAgent");
+
+ // Old request should be cancelled
+ assertEquals(ProfileChangeStatus.CANCELLED, oldRequest.getStatus());
+
+ // Two saves: one for cancel, one for new request
+ verify(changeRequestRepository, times(2)).save(any(ProfileChangeRequest.class));
+ }
+
+ // ===== Machine review: pass then human review =====
+
+ @Test
+ void updateProfile_machinePassAndHumanReview_shouldCreatePendingWithPassResult() {
+ var user = testUser();
+ when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
+ when(moderationConfig.machineReview()).thenReturn(true);
+ when(moderationConfig.humanReview()).thenReturn(true);
+ when(moderationService.moderate("user-1", displayNameChange("NewName")))
+ .thenReturn(ModerationResult.approved());
+ when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
+ .thenReturn(List.of());
+
+ var result = userProfileService.updateProfile(
+ "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
+
+ assertInstanceOf(UpdateProfileResult.PendingReview.class, result);
+
+ var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
+ verify(changeRequestRepository).save(captor.capture());
+ assertEquals("PASS", captor.getValue().getMachineResult());
+ }
+
+ // ===== Machine review: rejected =====
+
+ @Test
+ void updateProfile_machineRejected_shouldThrowAndSaveRejection() {
+ var user = testUser();
+ when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
+ when(moderationConfig.machineReview()).thenReturn(true);
+ when(moderationService.moderate("user-1", displayNameChange("BadWord")))
+ .thenReturn(ModerationResult.rejected("Contains sensitive content"));
+
+ var ex = assertThrows(IllegalArgumentException.class, () ->
+ userProfileService.updateProfile(
+ "user-1", displayNameChange("BadWord"), "req-1", "127.0.0.1", "TestAgent"));
+
+ assertEquals("Contains sensitive content", ex.getMessage());
+
+ // Change request should be saved as MACHINE_REJECTED
+ var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
+ verify(changeRequestRepository).save(captor.capture());
+ assertEquals(ProfileChangeStatus.MACHINE_REJECTED, captor.getValue().getStatus());
+ assertEquals("FAIL", captor.getValue().getMachineResult());
+
+ // user_account should NOT be updated
+ verify(userAccountRepository, never()).save(any());
+ }
+
+ // ===== User not found =====
+
+ @Test
+ void updateProfile_userNotFound_shouldThrow() {
+ when(userAccountRepository.findById("nonexistent")).thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class, () ->
+ userProfileService.updateProfile(
+ "nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent"));
+ }
+}
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java
new file mode 100644
index 00000000..cc8c48ae
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java
@@ -0,0 +1,21 @@
+package com.iflytek.skillhub.infra.jpa;
+
+import com.iflytek.skillhub.domain.user.ProfileChangeRequest;
+import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository;
+import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+/**
+ * JPA implementation of {@link ProfileChangeRequestRepository}.
+ * Spring Data derives query methods from method names automatically.
+ */
+@Repository
+public interface ProfileChangeRequestJpaRepository
+ extends JpaRepository, ProfileChangeRequestRepository {
+
+ @Override
+ List findByUserIdAndStatus(String userId, ProfileChangeStatus status);
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 25e5dc35..c861ea1d 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -858,6 +858,18 @@ export const meApi = {
},
}
+export const profileApi = {
+ async updateProfile(request: { displayName: string }): Promise<{ status: string }> {
+ return fetchJson<{ status: string }>('/api/v1/user/profile', {
+ method: 'PATCH',
+ headers: await ensureCsrfHeaders({
+ 'Content-Type': 'application/json',
+ }),
+ body: JSON.stringify(request),
+ })
+ },
+}
+
export const adminApi = {
async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index f70f0e1b..b6942b4a 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -103,6 +103,10 @@ const SecuritySettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/security'),
'SecuritySettingsPage',
)
+const ProfileSettingsPage = createLazyRouteComponent(
+ () => import('@/pages/settings/profile'),
+ 'ProfileSettingsPage',
+)
const AdminUsersPage = createRoleProtectedRouteComponent(
() => import('@/pages/admin/users'),
'AdminUsersPage',
@@ -327,6 +331,13 @@ const settingsSecurityRoute = createRoute({
component: SecuritySettingsPage,
})
+const settingsProfileRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'settings/profile',
+ beforeLoad: requireAuth,
+ component: ProfileSettingsPage,
+})
+
const settingsAccountsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'settings/accounts',
@@ -375,6 +386,7 @@ const routeTree = rootRoute.addChildren([
dashboardTokensRoute,
cliAuthRoute,
settingsSecurityRoute,
+ settingsProfileRoute,
settingsAccountsRoute,
adminUsersRoute,
adminAuditLogRoute,
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 4af7d31e..78eda345 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -509,6 +509,25 @@
"prevPage": "Previous",
"nextPage": "Next"
},
+ "profile": {
+ "title": "Profile Settings",
+ "subtitle": "Manage your display name and personal information.",
+ "displayName": "Display Name",
+ "email": "Email",
+ "edit": "Edit",
+ "save": "Save",
+ "saving": "Saving...",
+ "cancel": "Cancel",
+ "successTitle": "Profile Updated",
+ "successDescription": "Your display name has been updated.",
+ "pendingReviewTitle": "Submitted for Review",
+ "pendingReviewDescription": "Your display name change is pending review and will take effect once approved.",
+ "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."
+ }
+ },
"security": {
"title": "Security Settings",
"subtitle": "Update your password when local account login is enabled.",
@@ -891,6 +910,7 @@
"users": "User Management",
"auditLog": "Audit Log",
"security": "Security Settings",
+ "profile": "Profile Settings",
"accounts": "Account Merge",
"logout": "Logout"
}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 67d765f2..d12e9ff7 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -509,6 +509,25 @@
"prevPage": "上一页",
"nextPage": "下一页"
},
+ "profile": {
+ "title": "个人设置",
+ "subtitle": "管理你的昵称和个人信息。",
+ "displayName": "昵称",
+ "email": "邮箱",
+ "edit": "编辑",
+ "save": "保存",
+ "saving": "保存中...",
+ "cancel": "取消",
+ "successTitle": "修改成功",
+ "successDescription": "你的昵称已更新。",
+ "pendingReviewTitle": "已提交审核",
+ "pendingReviewDescription": "你的昵称修改正在等待审核,审核通过后将生效。",
+ "defaultError": "修改失败,请稍后重试。",
+ "validation": {
+ "length": "昵称长度需为 2-32 个字符。",
+ "pattern": "昵称只能包含中文、英文、数字、下划线和连字符。"
+ }
+ },
"security": {
"title": "安全设置",
"subtitle": "已启用本地账号密码登录时,可以在这里更新密码。",
@@ -891,6 +910,7 @@
"users": "用户管理",
"auditLog": "审计日志",
"security": "安全设置",
+ "profile": "个人设置",
"accounts": "账号合并",
"logout": "退出登录"
}
diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx
new file mode 100644
index 00000000..ce9327ff
--- /dev/null
+++ b/web/src/pages/settings/profile.tsx
@@ -0,0 +1,153 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useQueryClient } from '@tanstack/react-query'
+import { ApiError, profileApi } from '@/api/client'
+import { useAuth } from '@/features/auth/use-auth'
+import { truncateErrorMessage } from '@/shared/lib/error-display'
+import { toast } from '@/shared/lib/toast'
+import { Button } from '@/shared/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
+import { Input } from '@/shared/ui/input'
+
+/** Regex matching allowed display name characters: Chinese, English, digits, underscore, hyphen. */
+const DISPLAY_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z0-9_-]+$/
+
+export function ProfileSettingsPage() {
+ const { t } = useTranslation()
+ const { user } = useAuth()
+ const queryClient = useQueryClient()
+
+ const [isEditing, setIsEditing] = useState(false)
+ const [displayName, setDisplayName] = useState(user?.displayName ?? '')
+ const [errorMessage, setErrorMessage] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ function handleEdit() {
+ setDisplayName(user?.displayName ?? '')
+ setErrorMessage('')
+ setIsEditing(true)
+ }
+
+ function handleCancel() {
+ setIsEditing(false)
+ setErrorMessage('')
+ }
+
+ /** Client-side validation before submitting. */
+ function validate(value: string): string | null {
+ const trimmed = value.trim()
+ if (trimmed.length < 2 || trimmed.length > 32) {
+ return t('profile.validation.length')
+ }
+ if (!DISPLAY_NAME_PATTERN.test(trimmed)) {
+ return t('profile.validation.pattern')
+ }
+ return null
+ }
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault()
+ setErrorMessage('')
+
+ const trimmed = displayName.trim()
+ const validationError = validate(trimmed)
+ if (validationError) {
+ setErrorMessage(validationError)
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const result = await profileApi.updateProfile({ displayName: trimmed })
+
+ if (result.status === 'PENDING_REVIEW') {
+ toast.success(t('profile.pendingReviewTitle'), t('profile.pendingReviewDescription'))
+ } else {
+ toast.success(t('profile.successTitle'), t('profile.successDescription'))
+ // Refresh auth cache so the header and other components pick up the new name
+ await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
+ }
+
+ setIsEditing(false)
+ } catch (error) {
+ if (error instanceof ApiError) {
+ setErrorMessage(
+ truncateErrorMessage(error.message) ?? t('profile.defaultError'),
+ )
+ } else {
+ setErrorMessage(t('profile.defaultError'))
+ }
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ {t('profile.title')}
+ {t('profile.subtitle')}
+
+
+ {/* Avatar (read-only for now) */}
+ {user?.avatarUrl ? (
+
+
+
+ ) : null}
+
+ {/* Display name field */}
+
+
+ {/* Email (read-only) */}
+
+
{t('profile.email')}
+
{user?.email || '-'}
+
+
+
+
+ )
+}
diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx
index 5234e9fa..ace1df84 100644
--- a/web/src/shared/components/user-menu.tsx
+++ b/web/src/shared/components/user-menu.tsx
@@ -9,6 +9,7 @@ interface User {
displayName: string
avatarUrl?: string
platformRoles?: string[]
+ oauthProvider?: string
}
interface UserMenuProps {
@@ -29,6 +30,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
+ const isLocalAccount = !user.oauthProvider
const open = isHovered || isClickOpen
const clearCloseTimer = () => {
@@ -174,9 +176,14 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
) : null}
-
- {t('user.menu.security')}
+
+ {t('user.menu.profile')}
+ {isLocalAccount ? (
+
+ {t('user.menu.security')}
+
+ ) : null}
Date: Thu, 19 Mar 2026 10:44:13 +0800
Subject: [PATCH 02/22] fix: user profile test isolation and app module docs
(#94)
---
Makefile | 6 ++++++
README.md | 14 ++++++++++++++
README_zh.md | 4 ++++
.../controller/UserProfileControllerTest.java | 5 ++++-
4 files changed, 28 insertions(+), 1 deletion(-)
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/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 cf4202af..fcd240df 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,6 +3,7 @@ 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.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository;
import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
@@ -79,6 +80,9 @@ class UserProfileControllerTest {
@MockBean
private PlatformSessionService platformSessionService;
+ @MockBean
+ private AuditLogService auditLogService;
+
// -- Helper --
private PlatformPrincipal testPrincipal() {
@@ -225,4 +229,3 @@ class UserProfileControllerTest {
.andExpect(status().isBadRequest());
}
}
-
From c90199183a881ba2785f53524d26c8331cb7ac80 Mon Sep 17 00:00:00 2001
From: Xudong Sun
Date: Thu, 19 Mar 2026 10:49:54 +0800
Subject: [PATCH 03/22] feat: refine skill detail author badge and landing
publish CTA (#95)
* Replace skill author monogram with icon
* Update landing publish CTA copy
---
web/src/i18n/locales/zh.json | 2 +-
web/src/pages/skill-detail.tsx | 17 ++---------------
2 files changed, 3 insertions(+), 16 deletions(-)
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index d12e9ff7..7a6d7a3e 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": {
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx
index 45cebb24..2865b63a 100644
--- a/web/src/pages/skill-detail.tsx
+++ b/web/src/pages/skill-detail.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
+import { ArrowLeft, ChevronDown, ChevronUp, User } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { FileTree } from '@/features/skill/file-tree'
import { InstallCommand } from '@/features/skill/install-command'
@@ -68,19 +68,6 @@ function parseMetadataJson(parsed?: string) {
}
}
-function getAuthorMonogram(name?: string) {
- if (!name) {
- return '?'
- }
-
- const trimmed = name.trim()
- if (!trimmed) {
- return '?'
- }
-
- return trimmed[0]!.toUpperCase()
-}
-
function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null {
if (error.serverMessageKey === 'promotion.duplicate_pending') {
return 'promotion.duplicate_pending'
@@ -565,7 +552,7 @@ export function SkillDetailPage() {
- {getAuthorMonogram(skill.ownerDisplayName)}
+
{t('skillDetail.authorLabel', { name: skill.ownerDisplayName })}
From d126af15d810d94ab3cecc10f36970d769e6a9f2 Mon Sep 17 00:00:00 2001
From: Xudong Sun
Date: Thu, 19 Mar 2026 10:54:44 +0800
Subject: [PATCH 04/22] fix:change 'Start Building' to 'Publish Skill' (#96)
---
web/src/i18n/locales/en.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 78eda345..5b6c6336 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": {
From 6a62fec9e8fab6c162c57915f3629ec8beff937e Mon Sep 17 00:00:00 2001
From: Xudong Sun
Date: Thu, 19 Mar 2026 11:13:52 +0800
Subject: [PATCH 05/22] feat: allow spaces in profile display names (#97)
---
.../skillhub/dto/UpdateProfileRequest.java | 4 ++--
.../src/main/resources/messages.properties | 3 +--
.../src/main/resources/messages_zh.properties | 3 +--
.../controller/UserProfileControllerTest.java | 19 +++++++++++++++++++
web/src/i18n/locales/en.json | 2 +-
web/src/i18n/locales/zh.json | 2 +-
web/src/pages/settings/profile.tsx | 4 ++--
7 files changed, 27 insertions(+), 10 deletions(-)
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:
*
* Length: 2–32 characters (after trim)
- * Allowed characters: Chinese, English, digits, underscore, hyphen
+ * Allowed characters: Chinese, English, digits, spaces, underscore, hyphen
*
*
* @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/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties
index 7467829e..1ecfbac6 100644
--- a/server/skillhub-app/src/main/resources/messages.properties
+++ b/server/skillhub-app/src/main/resources/messages.properties
@@ -127,8 +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
-
diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties
index 5075d0ae..c09516be 100644
--- a/server/skillhub-app/src/main/resources/messages_zh.properties
+++ b/server/skillhub-app/src/main/resources/messages_zh.properties
@@ -127,8 +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=资料变更已提交审核
-
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 fcd240df..b4c25934 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
@@ -135,6 +135,25 @@ 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
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 5b6c6336..39f4d5e2 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -525,7 +525,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."
}
},
"security": {
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 7a6d7a3e..01651211 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -525,7 +525,7 @@
"defaultError": "修改失败,请稍后重试。",
"validation": {
"length": "昵称长度需为 2-32 个字符。",
- "pattern": "昵称只能包含中文、英文、数字、下划线和连字符。"
+ "pattern": "昵称只能包含中文、英文、数字、空格、下划线和连字符。"
}
},
"security": {
diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx
index ce9327ff..b54f235a 100644
--- a/web/src/pages/settings/profile.tsx
+++ b/web/src/pages/settings/profile.tsx
@@ -9,8 +9,8 @@ import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
-/** Regex matching allowed display name characters: Chinese, English, digits, underscore, hyphen. */
-const DISPLAY_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z0-9_-]+$/
+/** Regex matching allowed display name characters: Chinese, English, digits, spaces, underscore, hyphen. */
+const DISPLAY_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z0-9_ -]+$/
export function ProfileSettingsPage() {
const { t } = useTranslation()
From 8ef53d0fddea30004f23569ef0ad9636419a3ae7 Mon Sep 17 00:00:00 2001
From: vsxd
Date: Thu, 19 Mar 2026 12:57:45 +0800
Subject: [PATCH 06/22] docs: enrich backend code documentation
---
docs/17-backend-annotation-findings.md | 249 ++++++++++++++++++
.../iflytek/skillhub/SkillhubApplication.java | 3 +
.../bootstrap/BootstrapAdminProperties.java | 3 +
.../bootstrap/LocalDevDataInitializer.java | 3 +
.../skillhub/bootstrap/package-info.java | 5 +
.../compat/ClawHubCompatController.java | 4 +
.../compat/ClawHubRegistryFacade.java | 4 +
.../compat/ClawHubRegistrySecurityConfig.java | 4 +
.../skillhub/compat/SkillCoordinate.java | 3 +
.../skillhub/compat/WellKnownController.java | 3 +
.../skillhub/compat/dto/package-info.java | 5 +
.../iflytek/skillhub/compat/package-info.java | 5 +
.../iflytek/skillhub/config/AsyncConfig.java | 4 +
.../skillhub/config/DomainBeanConfig.java | 4 +
.../skillhub/config/OpenApiConfig.java | 3 +
.../config/WebMvcRateLimitConfig.java | 3 +
.../iflytek/skillhub/config/package-info.java | 5 +
.../controller/AccountMergeController.java | 4 +
.../skillhub/controller/AuthController.java | 29 ++
.../controller/BaseApiController.java | 3 +
.../controller/DeviceAuthController.java | 3 +
.../controller/DeviceAuthWebController.java | 4 +
.../skillhub/controller/HealthController.java | 3 +
.../controller/LocalAuthController.java | 3 +
.../skillhub/controller/TokenController.java | 3 +
.../admin/AdminSkillController.java | 4 +
.../admin/AdminSkillReportController.java | 4 +
.../controller/admin/AuditLogController.java | 3 +
.../admin/UserManagementController.java | 4 +
.../controller/admin/package-info.java | 5 +
.../skillhub/controller/package-info.java | 5 +
.../portal/GovernanceController.java | 4 +
.../controller/portal/MeController.java | 4 +
.../portal/NamespaceController.java | 4 +
.../portal/PromotionController.java | 4 +
.../controller/portal/ReviewController.java | 4 +
.../controller/portal/SkillController.java | 28 ++
.../portal/SkillLifecycleController.java | 4 +
.../portal/SkillPublishController.java | 10 +
.../portal/SkillRatingController.java | 3 +
.../portal/SkillReportController.java | 3 +
.../portal/SkillSearchController.java | 4 +
.../portal/SkillStarController.java | 3 +
.../controller/portal/SkillTagController.java | 3 +
.../controller/portal/package-info.java | 5 +
.../support/MultipartPackageExtractor.java | 4 +
.../support/ZipPackageExtractor.java | 3 +
.../controller/support/package-info.java | 5 +
.../iflytek/skillhub/dto/package-info.java | 5 +
.../exception/BadRequestException.java | 3 +
.../exception/ForbiddenException.java | 3 +
.../exception/GlobalExceptionHandler.java | 4 +
.../skillhub/exception/LocalizedError.java | 3 +
.../exception/LocalizedException.java | 3 +
.../exception/UnauthorizedException.java | 3 +
.../skillhub/exception/package-info.java | 5 +
.../skillhub/filter/AuthContextFilter.java | 3 +
.../filter/IdempotencyInterceptor.java | 15 ++
.../skillhub/filter/RequestIdFilter.java | 4 +
.../skillhub/filter/RequestLoggingFilter.java | 3 +
.../iflytek/skillhub/filter/package-info.java | 5 +
.../listener/SkillRatingEventListener.java | 4 +
.../listener/SkillStarEventListener.java | 3 +
.../skillhub/listener/package-info.java | 5 +
.../skillhub/metrics/SkillHubMetrics.java | 3 +
.../skillhub/metrics/package-info.java | 5 +
.../com/iflytek/skillhub/package-info.java | 7 +
.../AnonymousDownloadIdentityService.java | 4 +
.../skillhub/ratelimit/ClientIpResolver.java | 3 +
.../InMemorySlidingWindowRateLimiter.java | 3 +
.../iflytek/skillhub/ratelimit/RateLimit.java | 3 +
.../ratelimit/RateLimitInterceptor.java | 4 +
.../skillhub/ratelimit/RateLimiter.java | 3 +
.../RedisSlidingWindowRateLimiter.java | 3 +
.../skillhub/ratelimit/package-info.java | 5 +
.../repository/AdminUserSearchRepository.java | 3 +
.../skillhub/repository/package-info.java | 5 +
.../security/ApiAccessDeniedHandler.java | 3 +
.../security/ApiAuthenticationEntryPoint.java | 3 +
.../security/AuthFailureThrottleService.java | 3 +
.../security/SensitiveLogSanitizer.java | 3 +
.../skillhub/security/package-info.java | 5 +
.../service/AdminAuditLogAppService.java | 4 +
.../service/AdminSkillReportAppService.java | 4 +
.../skillhub/service/AdminUserAppService.java | 4 +
.../service/AdminUserManagementService.java | 4 +
.../skillhub/service/AuthMethodCatalog.java | 4 +
.../skillhub/service/DirectAuthService.java | 4 +
.../GovernanceWorkbenchAppService.java | 18 ++
.../skillhub/service/MySkillAppService.java | 4 +
.../NamespaceMemberCandidateService.java | 4 +
.../service/SessionBootstrapService.java | 4 +
.../service/SkillSearchAppService.java | 4 +
.../skillhub/service/package-info.java | 5 +
.../skillhub/task/IdempotencyCleanupTask.java | 4 +
.../iflytek/skillhub/task/package-info.java | 4 +
.../skillhub/auth/bootstrap/package-info.java | 5 +
.../auth/config/RedisTemplateConfig.java | 3 +
.../skillhub/auth/config/SecurityConfig.java | 15 ++
.../skillhub/auth/config/package-info.java | 5 +
.../auth/device/DeviceAuthService.java | 17 ++
.../skillhub/auth/device/package-info.java | 4 +
.../auth/direct/LocalDirectAuthProvider.java | 3 +
.../skillhub/auth/direct/package-info.java | 5 +
.../skillhub/auth/entity/package-info.java | 5 +
.../auth/exception/AuthFlowException.java | 4 +
.../skillhub/auth/exception/package-info.java | 5 +
.../auth/identity/IdentityBindingService.java | 4 +
.../skillhub/auth/identity/package-info.java | 5 +
.../skillhub/auth/local/LocalAuthService.java | 15 ++
.../auth/local/LocalCredentialRepository.java | 3 +
.../auth/local/PasswordPolicyValidator.java | 3 +
.../skillhub/auth/local/package-info.java | 5 +
.../merge/AccountMergeRequestRepository.java | 3 +
.../auth/merge/AccountMergeService.java | 4 +
.../skillhub/auth/merge/package-info.java | 5 +
.../skillhub/auth/mock/MockAuthFilter.java | 3 +
.../skillhub/auth/mock/package-info.java | 5 +
.../auth/oauth/AccountDisabledException.java | 3 +
.../auth/oauth/AccountPendingException.java | 3 +
.../auth/oauth/CustomOAuth2UserService.java | 4 +
.../auth/oauth/GitHubClaimsExtractor.java | 4 +
.../auth/oauth/OAuth2LoginFailureHandler.java | 4 +
.../auth/oauth/OAuth2LoginSuccessHandler.java | 4 +
.../skillhub/auth/oauth/OAuthClaims.java | 4 +
.../auth/oauth/OAuthClaimsExtractor.java | 3 +
.../auth/oauth/OAuthLoginRedirectSupport.java | 3 +
...HubOAuth2AuthorizationRequestResolver.java | 4 +
.../skillhub/auth/oauth/package-info.java | 5 +
.../skillhub/auth/policy/AccessDecision.java | 4 +
.../skillhub/auth/policy/AccessPolicy.java | 3 +
.../auth/policy/AccessPolicyFactory.java | 3 +
.../auth/policy/EmailDomainAccessPolicy.java | 3 +
.../auth/policy/OpenAccessPolicy.java | 3 +
.../policy/ProviderAllowlistAccessPolicy.java | 3 +
.../policy/SubjectWhitelistAccessPolicy.java | 3 +
.../skillhub/auth/policy/package-info.java | 5 +
.../skillhub/auth/rbac/PlatformPrincipal.java | 3 +
.../auth/rbac/PlatformRoleDefaults.java | 3 +
.../skillhub/auth/rbac/RbacService.java | 4 +
.../skillhub/auth/rbac/package-info.java | 5 +
.../auth/repository/ApiTokenRepository.java | 3 +
.../repository/IdentityBindingRepository.java | 3 +
.../auth/repository/RoleRepository.java | 3 +
.../repository/UserRoleBindingRepository.java | 3 +
.../auth/repository/package-info.java | 4 +
.../auth/session/PlatformSessionService.java | 16 ++
.../skillhub/auth/session/package-info.java | 5 +
.../token/ApiTokenAuthenticationFilter.java | 4 +
.../auth/token/ApiTokenScopeFilter.java | 3 +
.../auth/token/ApiTokenScopeService.java | 4 +
.../skillhub/auth/token/ApiTokenService.java | 24 ++
.../skillhub/auth/token/package-info.java | 5 +
.../domain/audit/AuditLogQueryService.java | 3 +
.../domain/audit/AuditLogRepository.java | 3 +
.../domain/audit/AuditLogService.java | 3 +
.../skillhub/domain/audit/package-info.java | 5 +
.../skillhub/domain/event/package-info.java | 4 +
.../GovernanceNotificationService.java | 3 +
.../UserNotificationRepository.java | 3 +
.../domain/governance/package-info.java | 5 +
.../IdempotencyRecordRepository.java | 3 +
.../domain/idempotency/package-info.java | 5 +
.../GlobalNamespaceMembershipService.java | 3 +
.../namespace/NamespaceAccessPolicy.java | 4 +
.../namespace/NamespaceGovernanceService.java | 4 +
.../namespace/NamespaceMemberRepository.java | 3 +
.../namespace/NamespaceMemberService.java | 4 +
.../domain/namespace/NamespaceRepository.java | 3 +
.../domain/namespace/NamespaceService.java | 24 ++
.../domain/namespace/SlugValidator.java | 3 +
.../domain/namespace/package-info.java | 5 +
.../domain/report/SkillReportRepository.java | 3 +
.../domain/report/SkillReportService.java | 4 +
.../skillhub/domain/report/package-info.java | 5 +
.../review/PromotionRequestRepository.java | 4 +
.../domain/review/PromotionService.java | 18 ++
.../review/ReviewPermissionChecker.java | 3 +
.../skillhub/domain/review/ReviewService.java | 27 ++
.../domain/review/ReviewTaskRepository.java | 3 +
.../skillhub/domain/review/package-info.java | 5 +
.../exception/DomainBadRequestException.java | 3 +
.../exception/DomainForbiddenException.java | 3 +
.../exception/DomainNotFoundException.java | 3 +
.../exception/LocalizedDomainException.java | 3 +
.../domain/shared/exception/package-info.java | 5 +
.../domain/skill/SkillFileRepository.java | 3 +
.../domain/skill/SkillRepository.java | 3 +
.../domain/skill/SkillTagRepository.java | 3 +
.../domain/skill/SkillVersionRepository.java | 3 +
.../skill/SkillVersionStatsRepository.java | 3 +
.../domain/skill/VisibilityChecker.java | 4 +
.../skill/metadata/SkillMetadataParser.java | 4 +
.../domain/skill/metadata/package-info.java | 4 +
.../skillhub/domain/skill/package-info.java | 5 +
.../skill/service/SkillDownloadService.java | 16 ++
.../skill/service/SkillGovernanceService.java | 4 +
.../SkillLifecycleProjectionService.java | 4 +
.../skill/service/SkillPublishService.java | 16 ++
.../skill/service/SkillQueryService.java | 23 ++
.../service/SkillSlugResolutionService.java | 3 +
.../domain/skill/service/SkillTagService.java | 4 +
.../domain/skill/service/package-info.java | 5 +
.../validation/BasicPrePublishValidator.java | 4 +
.../validation/NoOpPrePublishValidator.java | 3 +
.../skill/validation/PrePublishValidator.java | 4 +
.../skill/validation/SkillPackagePolicy.java | 12 +-
.../validation/SkillPackageValidator.java | 4 +
.../domain/skill/validation/package-info.java | 5 +
.../domain/social/SkillRatingRepository.java | 3 +
.../domain/social/SkillRatingService.java | 4 +
.../domain/social/SkillStarRepository.java | 3 +
.../domain/social/SkillStarService.java | 3 +
.../domain/social/event/package-info.java | 5 +
.../skillhub/domain/social/package-info.java | 5 +
.../domain/user/UserAccountRepository.java | 3 +
.../skillhub/domain/user/package-info.java | 5 +
.../infra/jpa/AuditLogJpaRepository.java | 3 +
.../jpa/JpaIdempotencyRecordRepository.java | 4 +
.../infra/jpa/JpaSkillRatingRepository.java | 3 +
.../infra/jpa/JpaSkillRepositoryAdapter.java | 3 +
.../infra/jpa/JpaSkillStarRepository.java | 3 +
.../infra/jpa/NamespaceJpaRepository.java | 3 +
.../jpa/NamespaceMemberJpaRepository.java | 3 +
.../jpa/PromotionRequestJpaRepository.java | 3 +
.../infra/jpa/ReviewTaskJpaRepository.java | 4 +
.../infra/jpa/SkillFileJpaRepository.java | 3 +
.../infra/jpa/SkillJpaRepository.java | 3 +
.../infra/jpa/SkillReportJpaRepository.java | 3 +
.../infra/jpa/SkillTagJpaRepository.java | 3 +
.../infra/jpa/SkillVersionJpaRepository.java | 3 +
.../jpa/SkillVersionStatsJpaRepository.java | 3 +
.../infra/jpa/UserAccountJpaRepository.java | 3 +
.../jpa/UserNotificationJpaRepository.java | 3 +
.../skillhub/infra/jpa/package-info.java | 5 +
.../search/HashingSearchEmbeddingService.java | 4 +
.../search/SearchEmbeddingService.java | 3 +
.../skillhub/search/SearchIndexService.java | 3 +
.../iflytek/skillhub/search/SearchQuery.java | 3 +
.../skillhub/search/SearchQueryService.java | 3 +
.../skillhub/search/SearchRebuildService.java | 3 +
.../iflytek/skillhub/search/SearchResult.java | 3 +
.../search/SearchVisibilityScope.java | 3 +
.../skillhub/search/SkillSearchDocument.java | 3 +
.../event/SearchIndexEventListener.java | 3 +
.../skillhub/search/event/package-info.java | 5 +
.../iflytek/skillhub/search/package-info.java | 4 +
.../PostgresFullTextIndexService.java | 3 +
.../PostgresFullTextQueryService.java | 11 +
.../PostgresSearchRebuildService.java | 3 +
.../search/postgres/package-info.java | 4 +
.../storage/LocalFileStorageService.java | 16 ++
.../skillhub/storage/ObjectMetadata.java | 3 +
.../storage/ObjectStorageService.java | 3 +
.../skillhub/storage/S3StorageService.java | 4 +
.../storage/StorageAccessException.java | 3 +
.../skillhub/storage/package-info.java | 5 +
257 files changed, 1476 insertions(+), 4 deletions(-)
create mode 100644 docs/17-backend-annotation-findings.md
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/security/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/package-info.java
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/package-info.java
create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/package-info.java
create mode 100644 server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/package-info.java
create mode 100644 server/skillhub-search/src/main/java/com/iflytek/skillhub/search/package-info.java
create mode 100644 server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/package-info.java
create mode 100644 server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/package-info.java
diff --git a/docs/17-backend-annotation-findings.md b/docs/17-backend-annotation-findings.md
new file mode 100644
index 00000000..b4f88640
--- /dev/null
+++ b/docs/17-backend-annotation-findings.md
@@ -0,0 +1,249 @@
+# 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.
+
+## 1. Admin user management is split across overlapping application services
+
+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.
+
+## 2. Several controllers still perform orchestration that belongs in application services
+
+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.
+
+## 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
+
+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.
+
+## 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/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/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..84e2d297 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
@@ -18,6 +18,10 @@ 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 {
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..5bee8282 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
@@ -29,6 +29,10 @@ 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 {
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/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..8c776c77 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
@@ -23,6 +23,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 {
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..7ea50453 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
@@ -19,6 +19,9 @@ import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
+/**
+ * Logs inbound HTTP requests and responses with truncation suitable for operational debugging.
+ */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class RequestLoggingFilter extends OncePerRequestFilter {
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
index fe7133f7..6109efeb 100644
--- 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
@@ -24,6 +24,10 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * Alternative user-management aggregation service that combines user records
+ * with role bindings for management-oriented views.
+ */
@Service
public class AdminUserManagementService {
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/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/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-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..6958b6db 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
@@ -29,6 +29,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
@@ -75,6 +79,13 @@ public class SecurityConfig {
this.mockAuthFilterProvider = mockAuthFilterProvider;
}
+ /**
+ * 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();
@@ -210,6 +221,10 @@ 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);
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/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..cb868267 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
@@ -9,6 +9,10 @@ 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 {
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-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..51b21845 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 {
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..bd65eac3 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,
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/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-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..1841a36a 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
@@ -13,6 +13,9 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
+/**
+ * Reconstructs PostgreSQL search documents from canonical skill and namespace records.
+ */
@Service
public class PostgresSearchRebuildService implements SearchRebuildService {
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-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;
From 729b1c04987af19e9332027dafba25d9d5e23bd1 Mon Sep 17 00:00:00 2001
From: vsxd
Date: Thu, 19 Mar 2026 13:05:13 +0800
Subject: [PATCH 07/22] docs: enrich frontend code documentation
---
docs/18-frontend-annotation-findings.md | 123 ++++++++++++++++++
web/src/api/client.ts | 8 ++
web/src/app/layout.tsx | 8 +-
web/src/app/providers.tsx | 6 +
web/src/app/router.tsx | 10 ++
web/src/bootstrap.ts | 6 +
web/src/features/admin/use-admin-users.ts | 5 +
web/src/features/admin/use-audit-log.ts | 3 +
web/src/features/auth/login-button.tsx | 3 +
.../features/auth/session-bootstrap-entry.tsx | 6 +
web/src/features/auth/use-auth-methods.ts | 3 +
web/src/features/auth/use-auth.ts | 6 +
web/src/features/auth/use-local-auth.ts | 3 +
web/src/features/auth/use-password-login.ts | 4 +
.../features/auth/use-session-bootstrap.ts | 4 +
.../governance/governance-activity.tsx | 5 +
.../features/governance/governance-inbox.tsx | 7 +
.../governance/governance-notifications.tsx | 5 +
web/src/features/governance/use-governance.ts | 5 +
.../namespace/add-namespace-member-dialog.tsx | 7 +
.../namespace/create-namespace-dialog.tsx | 9 ++
.../features/namespace/namespace-header.tsx | 3 +
.../features/namespace/use-my-namespaces.ts | 4 +
.../namespace/use-namespace-detail.ts | 5 +
.../namespace/use-namespace-members.ts | 5 +
.../features/promotion/use-promotion-list.ts | 14 ++
web/src/features/publish/upload-zone.tsx | 5 +
web/src/features/publish/use-publish-skill.ts | 5 +
web/src/features/report/use-skill-reports.ts | 13 ++
web/src/features/review/use-review-detail.ts | 20 +++
web/src/features/review/use-review-list.ts | 6 +
web/src/features/search/search-bar.tsx | 6 +
web/src/features/skill/file-tree.tsx | 9 ++
web/src/features/skill/markdown-renderer.tsx | 5 +
web/src/features/skill/skill-card.tsx | 3 +
web/src/features/social/star-button.tsx | 5 +
web/src/features/social/use-rating.ts | 14 ++
web/src/features/social/use-star.ts | 7 +
.../features/token/create-token-dialog.tsx | 6 +
web/src/features/token/token-list.tsx | 8 ++
web/src/i18n/config.ts | 5 +
web/src/main.tsx | 6 +
web/src/pages/admin/audit-log.tsx | 4 +
web/src/pages/admin/users.tsx | 6 +
web/src/pages/dashboard.tsx | 6 +
web/src/pages/dashboard/governance.tsx | 4 +
web/src/pages/dashboard/my-namespaces.tsx | 9 ++
web/src/pages/dashboard/my-skills.tsx | 6 +
web/src/pages/dashboard/namespace-members.tsx | 7 +
web/src/pages/dashboard/promotions.tsx | 7 +
web/src/pages/dashboard/publish.tsx | 6 +
web/src/pages/dashboard/reports.tsx | 25 ++++
web/src/pages/dashboard/review-detail.tsx | 7 +
web/src/pages/dashboard/reviews.tsx | 9 ++
web/src/pages/dashboard/tokens.tsx | 3 +
web/src/pages/landing.tsx | 24 ++--
web/src/pages/login.tsx | 6 +
web/src/pages/namespace.tsx | 3 +
web/src/pages/register.tsx | 3 +
web/src/pages/search.tsx | 8 ++
web/src/pages/settings/accounts.tsx | 15 +++
web/src/pages/settings/security.tsx | 9 ++
web/src/pages/skill-detail.tsx | 10 ++
.../components/dashboard-page-header.tsx | 3 +
web/src/shared/components/role-guard.tsx | 5 +
web/src/shared/hooks/use-skill-queries.ts | 9 +-
web/src/shared/lib/skill-lifecycle.ts | 4 +
web/src/shared/lib/skill-navigation.ts | 3 +
68 files changed, 580 insertions(+), 11 deletions(-)
create mode 100644 docs/18-frontend-annotation-findings.md
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/web/src/api/client.ts b/web/src/api/client.ts
index c861ea1d..5d92ecbc 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
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 b6942b4a..dfb5bf03 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/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..56a83584 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,6 +43,8 @@ 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'] })
},
})
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/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/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..1ef81573 100644
--- a/web/src/pages/admin/audit-log.tsx
+++ b/web/src/pages/admin/audit-log.tsx
@@ -35,6 +35,10 @@ const ACTION_OPTIONS = [
{ value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' },
] 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('')
diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx
index 7c4b18cc..bc3c2199 100644
--- a/web/src/pages/admin/users.tsx
+++ b/web/src/pages/admin/users.tsx
@@ -25,6 +25,10 @@ import { Label } from '@/shared/ui/label'
import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users'
import type { AdminUser } from '@/features/admin/use-admin-users'
+/**
+ * Admin user management page that combines search, status filtering, approval,
+ * activation control, and role changes in one route-level container.
+ */
export function AdminUsersPage() {
const { t, i18n } = useTranslation()
const roleOptions = [
@@ -81,6 +85,8 @@ export function AdminUsersPage() {
const handleChangeRole = (user: AdminUser) => {
setSelectedUser(user)
+ // The current backend model effectively treats the first platform role as
+ // the primary editable role in this screen.
setNewRole(user.platformRoles[0] || 'USER')
setRoleDialogOpen(true)
}
diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx
index 33160d4c..1b8f4e3b 100644
--- a/web/src/pages/dashboard.tsx
+++ b/web/src/pages/dashboard.tsx
@@ -10,6 +10,12 @@ import { limitPreviewItems } from './dashboard-preview'
const DASHBOARD_PREVIEW_LIMIT = 5
+/**
+ * Default dashboard landing page for authenticated users.
+ *
+ * It surfaces account context, quick links, and a lightweight preview of the user's latest skills
+ * and tokens before they move into more specialized dashboard sub-pages.
+ */
export function DashboardPage() {
const skillPreviewPageSize = DASHBOARD_PREVIEW_LIMIT
const { t } = useTranslation()
diff --git a/web/src/pages/dashboard/governance.tsx b/web/src/pages/dashboard/governance.tsx
index f2d6d4a6..db488b05 100644
--- a/web/src/pages/dashboard/governance.tsx
+++ b/web/src/pages/dashboard/governance.tsx
@@ -16,6 +16,10 @@ import {
type GovernanceInboxTab = 'ALL' | 'REVIEW' | 'PROMOTION' | 'REPORT'
+/**
+ * Dashboard page that aggregates governance summary counts, inbox queues, notifications, and
+ * recent moderation activity.
+ */
function SummaryCard({ label, value }: { label: string; value?: number }) {
return (
diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx
index c9add367..1803a35c 100644
--- a/web/src/pages/dashboard/my-namespaces.tsx
+++ b/web/src/pages/dashboard/my-namespaces.tsx
@@ -18,6 +18,11 @@ type PendingNamespaceAction =
| { action: 'archive'; slug: string; name: string }
| { action: 'restore'; slug: string; name: string }
+/**
+ * Dashboard page for namespaces the current user can manage or review. It owns
+ * namespace lifecycle actions because each action combines permissions, copy,
+ * and optimistic follow-up behavior that are specific to this route.
+ */
export function MyNamespacesPage() {
const navigate = useNavigate()
const { t } = useTranslation()
@@ -77,6 +82,10 @@ export function MyNamespacesPage() {
return t('myNamespaces.activeHint')
}
+ /**
+ * Centralizes dialog copy so lifecycle operations can reuse one confirmation
+ * component without scattering user-facing strings across event handlers.
+ */
const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => {
if (action === 'freeze') {
return {
diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx
index da406ba6..b41e36d3 100644
--- a/web/src/pages/dashboard/my-skills.tsx
+++ b/web/src/pages/dashboard/my-skills.tsx
@@ -15,6 +15,12 @@ import { ApiError } from '@/api/client'
const PAGE_SIZE = 10
+/**
+ * Dashboard page for skills owned by the current user.
+ *
+ * It combines lifecycle display, archive and unarchive actions, review withdrawal, and promotion
+ * submission into one management surface.
+ */
function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null {
if (error.serverMessageKey === 'promotion.duplicate_pending') {
return 'promotion.duplicate_pending'
diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx
index 84bfedc3..66184af6 100644
--- a/web/src/pages/dashboard/namespace-members.tsx
+++ b/web/src/pages/dashboard/namespace-members.tsx
@@ -22,6 +22,11 @@ type PendingRemoval = {
userId: string
}
+/**
+ * Member management page for a namespace. The route computes mutability from
+ * both namespace state and the current user's role because the backend model
+ * allows namespaces to become read-only for several independent reasons.
+ */
export function NamespaceMembersPage() {
const { t, i18n } = useTranslation()
const params = useParams({ from: '/dashboard/namespaces/$slug/members' })
@@ -40,6 +45,8 @@ export function NamespaceMembersPage() {
const currentNamespace = myNamespaces?.find((item) => item.slug === slug)
const currentUserRole = currentNamespace?.currentUserRole
const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE'
+ // Membership changes are only allowed in active team namespaces and only for
+ // elevated roles surfaced through the current user's namespace membership.
const canManageMembers = !isReadOnly && (currentUserRole === 'OWNER' || currentUserRole === 'ADMIN')
const readOnlyMessage = namespace?.type === 'GLOBAL'
diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx
index 2b5389db..a342443e 100644
--- a/web/src/pages/dashboard/promotions.tsx
+++ b/web/src/pages/dashboard/promotions.tsx
@@ -8,6 +8,10 @@ import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
+/**
+ * Renders one promotion queue lane. Pending items expose moderation actions,
+ * while historical lanes stay read-only and surface the review comment only.
+ */
function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) {
const { t, i18n } = useTranslation()
const { data: items, isLoading } = usePromotionList(status)
@@ -68,6 +72,9 @@ function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECT
)
}
+/**
+ * Dashboard page for namespace promotion requests.
+ */
export function PromotionsPage() {
const { t } = useTranslation()
return (
diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx
index 6f6172d2..64e6805e 100644
--- a/web/src/pages/dashboard/publish.tsx
+++ b/web/src/pages/dashboard/publish.tsx
@@ -11,6 +11,12 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
+/**
+ * Skill publish page used inside the dashboard.
+ *
+ * It coordinates namespace selection, visibility selection, zip upload, and backend publish error
+ * translation into user-facing toasts.
+ */
function isVersionExistsMessage(message?: string): boolean {
if (!message) {
return false
diff --git a/web/src/pages/dashboard/reports.tsx b/web/src/pages/dashboard/reports.tsx
index ab97827b..43fa6a03 100644
--- a/web/src/pages/dashboard/reports.tsx
+++ b/web/src/pages/dashboard/reports.tsx
@@ -12,6 +12,11 @@ import { REPORT_TEXT_WRAP_CLASS_NAME } from '@/features/report/report-text'
import { toast } from '@/shared/lib/toast'
import type { ReportDisposition } from '@/api/types'
+/**
+ * Moderation page for skill reports. The route keeps the confirmation state
+ * because different resolution dispositions map to different user-facing copy
+ * and backend side effects.
+ */
export function ReportsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
@@ -57,6 +62,10 @@ export function ReportsPage() {
}
}
+ /**
+ * Reuses one list renderer across status tabs while keeping pending-only
+ * moderation actions colocated with the rendered report card.
+ */
const renderList = (reports: typeof pendingReports, isLoading: boolean, status: 'PENDING' | 'RESOLVED' | 'DISMISSED') => {
if (isLoading) {
return (
@@ -184,30 +193,46 @@ export function ReportsPage() {
)
}
+/**
+ * Resolves the action label used by the confirmation dialog from the selected
+ * moderation disposition.
+ */
function resolveConfirmText(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHide')
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchive')
return t('reports.resolve')
}
+/**
+ * Chooses the confirmation body copy for the pending moderation action.
+ */
function resolveConfirmDescription(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string, skillLabel: string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideConfirmDescription', { skill: skillLabel })
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveConfirmDescription', { skill: skillLabel })
return t('reports.resolveConfirmDescription', { skill: skillLabel })
}
+/**
+ * Chooses the toast title shown after a successful resolution flow.
+ */
function resolveSuccessTitle(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideSuccessTitle')
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveSuccessTitle')
return t('reports.resolveSuccessTitle')
}
+/**
+ * Chooses the toast body shown after a successful resolution flow.
+ */
function resolveSuccessDescription(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string, skillLabel: string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideSuccessDescription', { skill: skillLabel })
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveSuccessDescription', { skill: skillLabel })
return t('reports.resolveSuccessDescription', { skill: skillLabel })
}
+/**
+ * Chooses the toast title shown when the resolution flow fails.
+ */
function resolveErrorTitle(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideErrorTitle')
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveErrorTitle')
diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx
index f5219fe7..7153b191 100644
--- a/web/src/pages/dashboard/review-detail.tsx
+++ b/web/src/pages/dashboard/review-detail.tsx
@@ -11,6 +11,11 @@ import { toast } from '@/shared/lib/toast'
import { resolveReviewActionErrorDescription } from '@/features/review/review-error'
import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail'
+/**
+ * Review task detail page for moderators. The route owns the approve/reject
+ * interaction state because both actions depend on route-local confirmation
+ * dialogs, comment input, and redirect behavior after completion.
+ */
export function ReviewDetailPage() {
const { id } = useParams({ from: '/dashboard/reviews/$id' })
const navigate = useNavigate()
@@ -51,6 +56,8 @@ export function ReviewDetailPage() {
}
const handleReject = async () => {
+ // Rejections require explicit operator feedback so submitters can understand
+ // what must change before the package is resubmitted.
if (!comment.trim()) {
toast.error(t('review.rejectReasonRequired'))
return
diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx
index 53b33028..e4ce5531 100644
--- a/web/src/pages/dashboard/reviews.tsx
+++ b/web/src/pages/dashboard/reviews.tsx
@@ -14,6 +14,11 @@ import { useReviewList } from '@/features/review/use-review-list'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { formatLocalDateTime } from '@/shared/lib/date-time'
+/**
+ * Dashboard review queue page. Each tab materializes one review status because
+ * the moderation workflow treats pending, approved, and rejected queues as
+ * distinct operator views rather than one filterable table.
+ */
export function ReviewsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
@@ -29,6 +34,10 @@ export function ReviewsPage() {
navigate({ to: `/dashboard/reviews/${reviewId}` })
}
+ /**
+ * Keeps the table rendering logic in one local helper so the per-status tabs
+ * stay declarative while still allowing columns to differ by workflow state.
+ */
const renderReviewTable = (reviews: typeof pendingReviews, isLoading: boolean, status: string) => {
if (isLoading) {
return (
diff --git a/web/src/pages/dashboard/tokens.tsx b/web/src/pages/dashboard/tokens.tsx
index 5f8a478e..6076eac4 100644
--- a/web/src/pages/dashboard/tokens.tsx
+++ b/web/src/pages/dashboard/tokens.tsx
@@ -2,6 +2,9 @@ import { useTranslation } from 'react-i18next'
import { TokenList } from '@/features/token/token-list'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
+/**
+ * Dedicated dashboard page for managing personal API tokens.
+ */
export function TokensPage() {
const { t } = useTranslation()
return (
diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx
index 862d6456..8fb7232f 100644
--- a/web/src/pages/landing.tsx
+++ b/web/src/pages/landing.tsx
@@ -9,6 +9,12 @@ import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { useInView } from '@/shared/hooks/use-in-view'
import { Button } from '@/shared/ui/button'
+/**
+ * Marketing-style landing page for unauthenticated and first-time visitors.
+ *
+ * The page mixes static positioning content with live skill queries so popular and latest skills
+ * stay aligned with the current registry state.
+ */
export function LandingPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -60,25 +66,25 @@ export function LandingPage() {
},
{
icon: ,
- title: t('landing.features.versionControl.title', { defaultValue: '版本控制' }),
- description: t('landing.features.versionControl.description', { defaultValue: '完善的版本管理和发布流程,确保技能包的质量和可追溯性。' }),
+ title: t('landing.features.versionControl.title', { defaultValue: 'Version control' }),
+ description: t('landing.features.versionControl.description', { defaultValue: 'Managed release flows keep skill packages traceable and easier to review.' }),
},
{
icon: ,
- title: t('landing.features.cli.title', { defaultValue: 'CLI 工具' }),
- description: t('landing.features.cli.description', { defaultValue: '强大的命令行工具,支持快速发布、安装和管理技能包。' }),
+ title: t('landing.features.cli.title', { defaultValue: 'CLI tooling' }),
+ description: t('landing.features.cli.description', { defaultValue: 'Command-line workflows support publishing, installing, and operating skills quickly.' }),
},
{
icon: ,
- title: t('landing.features.governance.title', { defaultValue: '审核治理' }),
- description: t('landing.features.governance.description', { defaultValue: '内置审核流程和权限管理,保障企业级技能质量。' }),
+ title: t('landing.features.governance.title', { defaultValue: 'Governance' }),
+ description: t('landing.features.governance.description', { defaultValue: 'Built-in review and permission flows help teams enforce skill quality.' }),
},
]
const stats = [
- { value: '1000+', label: t('landing.stats.skills', { defaultValue: '项目库' }) },
- { value: '50K+', label: t('landing.stats.downloads', { defaultValue: '下载量' }) },
- { value: '200+', label: t('landing.stats.teams', { defaultValue: '团队' }) },
+ { value: '1000+', label: t('landing.stats.skills', { defaultValue: 'Registry items' }) },
+ { value: '50K+', label: t('landing.stats.downloads', { defaultValue: 'Downloads' }) },
+ { value: '200+', label: t('landing.stats.teams', { defaultValue: 'Teams' }) },
]
return (
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx
index b22c722e..291e253d 100644
--- a/web/src/pages/login.tsx
+++ b/web/src/pages/login.tsx
@@ -11,6 +11,12 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
+/**
+ * Authentication entry page.
+ *
+ * It combines password login, OAuth entry points, and optional session-bootstrap support while
+ * preserving the route the user originally intended to visit.
+ */
export function LoginPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx
index 207234ba..ac274cbe 100644
--- a/web/src/pages/namespace.tsx
+++ b/web/src/pages/namespace.tsx
@@ -6,6 +6,9 @@ import { SkeletonList } from '@/shared/components/skeleton-loader'
import { EmptyState } from '@/shared/components/empty-state'
import { useNamespaceDetail, useSearchSkills } from '@/shared/hooks/use-skill-queries'
+/**
+ * Public namespace page showing namespace metadata and the skills currently discoverable inside it.
+ */
export function NamespacePage() {
const { t } = useTranslation()
const navigate = useNavigate()
diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx
index 0075fe8b..93d67803 100644
--- a/web/src/pages/register.tsx
+++ b/web/src/pages/register.tsx
@@ -8,6 +8,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha
import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
+/**
+ * Registration page for local accounts with an alternate OAuth-based entry path.
+ */
export function RegisterPage() {
const { t } = useTranslation()
const navigate = useNavigate()
diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx
index 7c47ba89..1bfd5e18 100644
--- a/web/src/pages/search.tsx
+++ b/web/src/pages/search.tsx
@@ -15,6 +15,12 @@ import { Button } from '@/shared/ui/button'
const PAGE_SIZE = 12
+/**
+ * Skill discovery page with synchronized URL state.
+ *
+ * Search text, sorting, pagination, and the starred-only filter are mirrored into router search
+ * params so the page can be shared, restored, and revisited without losing state.
+ */
function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
@@ -69,6 +75,8 @@ export function SearchPage() {
} = useMyStars(starredOnly && isAuthenticated)
useEffect(() => {
+ // Debounce URL updates while the user is typing so query state stays shareable without
+ // triggering a navigation on every keystroke.
const normalizedQuery = normalizeSearchQuery(queryInput)
if (normalizedQuery === q) {
return
diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx
index b9df721e..fb13a933 100644
--- a/web/src/pages/settings/accounts.tsx
+++ b/web/src/pages/settings/accounts.tsx
@@ -6,6 +6,11 @@ import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
+/**
+ * Account linking settings page for the multi-step account merge workflow.
+ * The route intentionally keeps all three steps visible because operators often
+ * need to paste ids or tokens across systems while completing the merge.
+ */
export function AccountSettingsPage() {
const { t } = useTranslation()
const [secondaryIdentifier, setSecondaryIdentifier] = useState('')
@@ -17,6 +22,10 @@ export function AccountSettingsPage() {
const verifyMutation = useVerifyAccountMerge()
const confirmMutation = useConfirmAccountMerge()
+ /**
+ * Starts the merge flow and surfaces the request id plus verification token
+ * returned by the backend for the following steps.
+ */
async function handleInitiate(event: React.FormEvent) {
event.preventDefault()
setStatusMessage('')
@@ -32,6 +41,9 @@ export function AccountSettingsPage() {
}
}
+ /**
+ * Verifies ownership of the secondary account before the final merge step.
+ */
async function handleVerify(event: React.FormEvent) {
event.preventDefault()
setStatusMessage('')
@@ -48,6 +60,9 @@ export function AccountSettingsPage() {
}
}
+ /**
+ * Finalizes the merge after verification has succeeded.
+ */
async function handleConfirm() {
setStatusMessage('')
try {
diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx
index 85b84ab0..abd90d2b 100644
--- a/web/src/pages/settings/security.tsx
+++ b/web/src/pages/settings/security.tsx
@@ -9,6 +9,11 @@ import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
+/**
+ * Security settings page for password changes. After a successful change the
+ * user is logged out so all existing authenticated state is re-established with
+ * the new credential.
+ */
export function SecuritySettingsPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -18,6 +23,10 @@ export function SecuritySettingsPage() {
const [errorMessage, setErrorMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
+ /**
+ * Submits the password change request and clears local auth state afterward,
+ * even if the explicit logout request fails.
+ */
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setErrorMessage('')
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx
index 2865b63a..aed3dbe3 100644
--- a/web/src/pages/skill-detail.tsx
+++ b/web/src/pages/skill-detail.tsx
@@ -47,6 +47,12 @@ import {
useWithdrawSkillReview,
} from '@/shared/hooks/use-skill-queries'
+/**
+ * Detail page for one skill and its version history.
+ *
+ * This page coordinates documentation rendering, file browsing, downloads, lifecycle actions,
+ * promotion/report dialogs, and social interactions for the selected skill.
+ */
function suggestNextVersion(version: string) {
const semverMatch = version.match(/^(\d+)\.(\d+)\.(\d+)$/)
if (semverMatch) {
@@ -132,6 +138,8 @@ export function SkillDetailPage() {
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
useEffect(() => {
+ // Recompute collapse rules whenever rendered documentation height changes so the page can keep
+ // a readable summary section on different screen sizes.
if (!readme || typeof window === 'undefined') {
setIsOverviewCollapsible(false)
setIsOverviewExpanded(false)
@@ -188,6 +196,8 @@ export function SkillDetailPage() {
}
const refreshSkill = () => {
+ // Several actions mutate derived lifecycle state; refresh the shared skill detail and common
+ // list caches together to keep the rest of the app consistent.
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug, 'versions'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
diff --git a/web/src/shared/components/dashboard-page-header.tsx b/web/src/shared/components/dashboard-page-header.tsx
index 33715403..1266fce3 100644
--- a/web/src/shared/components/dashboard-page-header.tsx
+++ b/web/src/shared/components/dashboard-page-header.tsx
@@ -9,6 +9,9 @@ interface DashboardPageHeaderProps {
actions?: React.ReactNode
}
+/**
+ * Standard header used by dashboard sub-pages so navigation and page framing stay consistent.
+ */
export function DashboardPageHeader({ title, subtitle, actions }: DashboardPageHeaderProps) {
const { t } = useTranslation()
const navigate = useNavigate()
diff --git a/web/src/shared/components/role-guard.tsx b/web/src/shared/components/role-guard.tsx
index 62ca6c38..c3981721 100644
--- a/web/src/shared/components/role-guard.tsx
+++ b/web/src/shared/components/role-guard.tsx
@@ -10,6 +10,9 @@ interface RoleGuardProps {
children: ReactNode
}
+/**
+ * Client-side role guard used by protected route components after authentication has resolved.
+ */
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -19,6 +22,8 @@ export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const isAllowed = canAccessRoute(user?.platformRoles, allowedRoles)
useEffect(() => {
+ // Only handle the forbidden path once per mount so toasts and redirects do not repeat while the
+ // auth query refetches.
if (isLoading || !user || isAllowed || hasHandledForbiddenRef.current) {
return
}
diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts
index f9b9b24d..d9d46b83 100644
--- a/web/src/shared/hooks/use-skill-queries.ts
+++ b/web/src/shared/hooks/use-skill-queries.ts
@@ -4,6 +4,12 @@ import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, promotionApi
import { appendNamespaceMember, replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache'
import { buildSkillSearchUrl, shouldEnableNamespaceMemberCandidates } from './skill-query-helpers'
+/**
+ * Shared TanStack Query hooks for skill, namespace, and related dashboard data.
+ *
+ * This file currently acts as a broad query gateway for several features, centralizing cache keys,
+ * backend fetchers, and invalidation rules used throughout the app.
+ */
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
async function searchSkills(params: SearchParams): Promise> {
@@ -103,7 +109,8 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
})
}
-// Hooks
+// Query hooks stay close to the low-level fetchers so cache keys and invalidation rules remain
+// consistent across pages and feature wrappers.
export function useSearchSkills(params: SearchParams) {
return useQuery({
queryKey: ['skills', 'search', params],
diff --git a/web/src/shared/lib/skill-lifecycle.ts b/web/src/shared/lib/skill-lifecycle.ts
index 5f715a62..b95f2b45 100644
--- a/web/src/shared/lib/skill-lifecycle.ts
+++ b/web/src/shared/lib/skill-lifecycle.ts
@@ -3,6 +3,10 @@ import type { SkillDetail, SkillLifecycleVersion, SkillSummary } from '@/api/typ
type SkillLifecycleCarrier = Pick
| Pick
+/**
+ * Small lifecycle helpers shared by list cards and detail pages so version-display rules stay
+ * aligned with backend projections.
+ */
export function getHeadlineVersion(skill: SkillLifecycleCarrier): SkillLifecycleVersion | null {
return skill.headlineVersion ?? null
}
diff --git a/web/src/shared/lib/skill-navigation.ts b/web/src/shared/lib/skill-navigation.ts
index 90a8d9d4..18d19ad6 100644
--- a/web/src/shared/lib/skill-navigation.ts
+++ b/web/src/shared/lib/skill-navigation.ts
@@ -1,3 +1,6 @@
+/**
+ * Helpers for constructing and validating navigation state around skill-detail pages.
+ */
export function getSkillSquareSearch() {
return {
q: '',
From f26a06d42fec09a9f7f8f1554508ba8c9008867c Mon Sep 17 00:00:00 2001
From: vsxd
Date: Thu, 19 Mar 2026 14:12:25 +0800
Subject: [PATCH 08/22] docs: simplify skillhub registry guide
---
web/src/docs/skill.md | 82 +++++++++----------------------------------
1 file changed, 17 insertions(+), 65 deletions(-)
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.
From ac94930c9427d261a01254a64447c3facfd81a9c Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 09:47:57 +0800
Subject: [PATCH 09/22] fix: hide governance entry for unauthorized users
---
web/src/pages/dashboard.tsx | 19 +++++++++++--------
web/src/shared/components/user-menu.tsx | 10 +++++++---
web/src/shared/lib/governance-access.test.ts | 16 ++++++++++++++++
web/src/shared/lib/governance-access.ts | 9 +++++++++
4 files changed, 43 insertions(+), 11 deletions(-)
create mode 100644 web/src/shared/lib/governance-access.test.ts
create mode 100644 web/src/shared/lib/governance-access.ts
diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx
index 1b8f4e3b..e9daa899 100644
--- a/web/src/pages/dashboard.tsx
+++ b/web/src/pages/dashboard.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import type { SkillSummary } from '@/api/types'
import { useMySkills } from '@/shared/hooks/use-skill-queries'
+import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
import { TokenList } from '@/features/token/token-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
@@ -19,8 +20,8 @@ const DASHBOARD_PREVIEW_LIMIT = 5
export function DashboardPage() {
const skillPreviewPageSize = DASHBOARD_PREVIEW_LIMIT
const { t } = useTranslation()
- const { user, hasRole } = useAuth()
- const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
+ const { user } = useAuth()
+ const governanceVisible = canViewGovernanceCenter(user?.platformRoles)
const { data: skillPage, isLoading: isLoadingSkills } = useMySkills({ page: 0, size: skillPreviewPageSize })
const skillPreview = limitPreviewItems(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT)
@@ -93,12 +94,14 @@ export function DashboardPage() {
{t('dashboard.openTokens')}
-
- {t('dashboard.governanceTitle')}
-
- {t('dashboard.viewGovernance')}
-
-
+ {governanceVisible ? (
+
+ {t('dashboard.governanceTitle')}
+
+ {t('dashboard.viewGovernance')}
+
+
+ ) : null}
{governanceVisible ? (
{t('dashboard.reportsTitle')}
diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx
index ace1df84..e1b6c631 100644
--- a/web/src/shared/components/user-menu.tsx
+++ b/web/src/shared/components/user-menu.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/api/client'
+import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { cn } from '@/shared/lib/utils'
interface User {
@@ -27,6 +28,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN')
+ const canSeeGovernance = canViewGovernanceCenter(user.platformRoles)
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
@@ -143,9 +145,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
{t('user.menu.myNamespaces')}
-
- {t('user.menu.governance')}
-
+ {canSeeGovernance ? (
+
+ {t('user.menu.governance')}
+
+ ) : null}
{t('user.menu.stars')}
diff --git a/web/src/shared/lib/governance-access.test.ts b/web/src/shared/lib/governance-access.test.ts
new file mode 100644
index 00000000..3e2f61f0
--- /dev/null
+++ b/web/src/shared/lib/governance-access.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { canViewGovernanceCenter } from './governance-access'
+
+describe('canViewGovernanceCenter', () => {
+ it('allows governance reviewers and admins', () => {
+ expect(canViewGovernanceCenter(['SKILL_ADMIN'])).toBe(true)
+ expect(canViewGovernanceCenter(['NAMESPACE_ADMIN'])).toBe(true)
+ expect(canViewGovernanceCenter(['SUPER_ADMIN'])).toBe(true)
+ })
+
+ it('hides governance center for users without governance roles', () => {
+ expect(canViewGovernanceCenter(['USER'])).toBe(false)
+ expect(canViewGovernanceCenter(['AUDITOR'])).toBe(false)
+ expect(canViewGovernanceCenter(undefined)).toBe(false)
+ })
+})
diff --git a/web/src/shared/lib/governance-access.ts b/web/src/shared/lib/governance-access.ts
new file mode 100644
index 00000000..87409fc8
--- /dev/null
+++ b/web/src/shared/lib/governance-access.ts
@@ -0,0 +1,9 @@
+export function canViewGovernanceCenter(platformRoles?: readonly string[]) {
+ if (!platformRoles?.length) {
+ return false
+ }
+
+ return platformRoles.includes('SKILL_ADMIN')
+ || platformRoles.includes('NAMESPACE_ADMIN')
+ || platformRoles.includes('SUPER_ADMIN')
+}
From 1a5b8de19664f5af494289aeeb990e1485fe1b01 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 10:32:46 +0800
Subject: [PATCH 10/22] fix: handle archived skill version deletion
---
.../skill/service/SkillGovernanceService.java | 5 +++
.../service/SkillGovernanceServiceTest.java | 29 +++++++++++++++
web/src/features/skill/code-language.test.ts | 4 +--
web/src/features/skill/code-language.ts | 35 ++++++++++++++++---
web/src/shared/hooks/use-in-view.ts | 7 +++-
5 files changed, 72 insertions(+), 8 deletions(-)
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 51b21845..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
@@ -161,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/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/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/shared/hooks/use-in-view.ts b/web/src/shared/hooks/use-in-view.ts
index d717f0b3..da1c1bc7 100644
--- a/web/src/shared/hooks/use-in-view.ts
+++ b/web/src/shared/hooks/use-in-view.ts
@@ -3,6 +3,11 @@ import { useEffect, useRef, useState } from 'react'
export function useInView(options?: IntersectionObserverInit) {
const ref = useRef(null)
const [inView, setInView] = useState(false)
+ const optionsRef = useRef(options)
+
+ useEffect(() => {
+ optionsRef.current = options
+ }, [options])
useEffect(() => {
const el = ref.current
@@ -15,7 +20,7 @@ export function useInView(options?: IntersectionObserverInit) {
observer.unobserve(el)
}
},
- { threshold: 0.15, ...options },
+ { threshold: 0.15, ...optionsRef.current },
)
observer.observe(el)
From a4cb9f0cf980cd1861377c4e046b650f8253959a Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 10:54:32 +0800
Subject: [PATCH 11/22] fix(web): disable report button after successful
submission
---
web/src/i18n/locales/en.json | 1 +
web/src/i18n/locales/zh.json | 1 +
web/src/pages/skill-detail.tsx | 6 ++++--
3 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 39f4d5e2..c738818e 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -695,6 +695,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",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 01651211..aecfb793 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -695,6 +695,7 @@
"promotionAlreadyPromotedDescription": "这个技能已经提升到全局命名空间,无需重复提交。",
"promotionErrorTitle": "提交提升申请失败",
"reportSkill": "举报技能",
+ "reportedSkill": "已举报",
"reportDialogTitle": "举报技能",
"reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。",
"reportReasonPlaceholder": "举报原因,例如包含违规内容、恶意误导、侵权等",
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx
index aed3dbe3..ec792bef 100644
--- a/web/src/pages/skill-detail.tsx
+++ b/web/src/pages/skill-detail.tsx
@@ -93,6 +93,7 @@ export function SkillDetailPage() {
const [reportDialogOpen, setReportDialogOpen] = useState(false)
const [reportReason, setReportReason] = useState('')
const [reportDetails, setReportDetails] = useState('')
+ const [hasReported, setHasReported] = useState(false)
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
const [promotionConfirmOpen, setPromotionConfirmOpen] = useState(false)
@@ -288,6 +289,7 @@ export function SkillDetailPage() {
setReportDialogOpen(false)
setReportReason('')
setReportDetails('')
+ setHasReported(true)
toast.success(t('skillDetail.reportSuccessTitle'), t('skillDetail.reportSuccessDescription'))
} catch (error) {
toast.error(t(resolveSkillActionErrorTitle('report')), error instanceof Error ? error.message : '')
@@ -788,8 +790,8 @@ export function SkillDetailPage() {
{canReport ? (
-
- {reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.reportSkill')}
+
+ {hasReported ? t('skillDetail.reportedSkill') : reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.reportSkill')}
) : null}
>
From 7222ff0f377ad6afbb760ef1cfe503f84f55f6c0 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 11:11:14 +0800
Subject: [PATCH 12/22] fix(web): add rejected badge for skill versions that
failed review
---
web/src/i18n/locales/en.json | 1 +
web/src/i18n/locales/zh.json | 1 +
web/src/pages/dashboard/my-skills.tsx | 5 +++++
web/src/pages/skill-detail.tsx | 6 ++++++
4 files changed, 13 insertions(+)
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index c738818e..acff31cf 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -614,6 +614,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.",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index aecfb793..9b810451 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -614,6 +614,7 @@
"statusArchived": "已归档",
"statusHidden": "已隐藏",
"pendingPreviewBadge": "待审核预览",
+ "rejectedBadge": "审核未通过",
"pendingPreviewTitle": "当前正在预览待审核版本",
"pendingPreviewDescription": "该版本仅你本人可见。审核通过前,你可以查看 README、文件和版本信息,但不能收藏、评分、举报或下载。",
"pendingPreviewInteractionHint": "待审核预览期间不可收藏、评分或举报。",
diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx
index b41e36d3..a4b150b7 100644
--- a/web/src/pages/dashboard/my-skills.tsx
+++ b/web/src/pages/dashboard/my-skills.tsx
@@ -250,6 +250,11 @@ export function MySkillsPage() {
{resolveStatusLabel(ownerPreviewVersion?.status)}
) : null}
+ {!hasPendingPreview && ownerPreviewVersion?.status === 'REJECTED' && ownerPreviewVersion?.version !== headlineVersion?.version ? (
+
+ {resolveStatusLabel('REJECTED')}
+
+ ) : null}
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx
index ec792bef..2f0d9d6c 100644
--- a/web/src/pages/skill-detail.tsx
+++ b/web/src/pages/skill-detail.tsx
@@ -133,6 +133,7 @@ export function SkillDetailPage() {
const canHideSkill = hasRole('SUPER_ADMIN')
const isPendingPreview = skill ? isOwnerPreviewResolution(skill) : false
const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW'
+ const hasRejectedVersion = versions?.some((v) => v.status === 'REJECTED') ?? false
const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview)
const canInteract = skill?.canInteract ?? true
const canReport = skill?.canReport ?? true
@@ -558,6 +559,11 @@ export function SkillDetailPage() {
{t('skillDetail.pendingPreviewBadge')}
)}
+ {!isPendingPreview && hasRejectedVersion && skill.canManageLifecycle && (
+
+ {t('skillDetail.rejectedBadge')}
+
+ )}
{skill.displayName}
{skill.ownerDisplayName && (
From 7d8915fc7adfb827826920df222b158e4bf4161d Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 11:13:36 +0800
Subject: [PATCH 13/22] fix(web): prevent version badges from overlapping
action buttons in version list
---
web/src/pages/skill-detail.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx
index 2f0d9d6c..5866d4eb 100644
--- a/web/src/pages/skill-detail.tsx
+++ b/web/src/pages/skill-detail.tsx
@@ -676,8 +676,8 @@ export function SkillDetailPage() {
{versions.map((version) => (
-
-
+
+
v{version.version}
@@ -694,7 +694,7 @@ export function SkillDetailPage() {
)}
-
+
{formatLocalDateTime(version.publishedAt, i18n.language)}
From ea756702243bf8692cccdc13bbc7c9451dd24350 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 13:53:30 +0800
Subject: [PATCH 14/22] fix(server): update skill visibility on each version
publish
---
.../skillhub/domain/skill/service/SkillPublishService.java | 3 +++
1 file changed, 3 insertions(+)
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 bd65eac3..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
@@ -236,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);
}
From 245d92a4c1511180b09222b41ce57a2c114991f1 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 13:57:58 +0800
Subject: [PATCH 15/22] test(server): add unit test for visibility update on
republish
---
.../service/SkillPublishServiceTest.java | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
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);
From db92d17b25b7e299b1ad850ce6d888e1c929dc54 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 13:58:21 +0800
Subject: [PATCH 16/22] fix(web): remove misleading visibility statement from
publish review description
---
web/src/i18n/locales/en.json | 2 +-
web/src/i18n/locales/zh.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index acff31cf..e7fe9007 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -931,7 +931,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 9b810451..0afce248 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -931,7 +931,7 @@
"subtitle": "上传技能包到 SkillHub",
"reviewNotice": {
"title": "发布审核说明",
- "description": "技能包提交后需要经过管理员审核才能正式发布。审核通过后,您的技能将对所有用户可见。"
+ "description": "技能包提交后需要经过管理员审核才能正式发布。"
},
"namespace": "命名空间",
"selectNamespace": "选择命名空间",
From 4365a9363c071b5818521d6994f96aa976a31ce8 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 13:59:44 +0800
Subject: [PATCH 17/22] fix(web): remove default focus outline on user menu
trigger button
---
web/src/shared/components/user-menu.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx
index e1b6c631..c1ca8f75 100644
--- a/web/src/shared/components/user-menu.tsx
+++ b/web/src/shared/components/user-menu.tsx
@@ -111,7 +111,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
type="button"
aria-expanded={open}
aria-haspopup="menu"
- className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity', triggerClassName)}
+ className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-md', triggerClassName)}
onClick={() => setIsClickOpen((current) => !current)}
>
{user.avatarUrl && (
From cd570ed208c4e09307e27f1d2171e2016d0f3ec0 Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 14:01:23 +0800
Subject: [PATCH 18/22] fix(web): swap password visibility toggle eye icons
---
web/src/pages/login.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx
index 291e253d..a23d0e3e 100644
--- a/web/src/pages/login.tsx
+++ b/web/src/pages/login.tsx
@@ -147,7 +147,7 @@ export function LoginPage() {
onClick={() => setShowPassword((current) => !current)}
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
- {showPassword ? : }
+ {showPassword ? : }
{fieldErrors.password ? (
From 049f5acb64761eaeaa69e9e1db12450fb8f8489c Mon Sep 17 00:00:00 2001
From: yun-zhi-ztl <15071461069@163.com>
Date: Thu, 19 Mar 2026 14:13:39 +0800
Subject: [PATCH 19/22] refactor(server): slim down request logging to core
parameters only
---
.../skillhub/filter/RequestLoggingFilter.java | 83 +++++++++----------
.../src/main/resources/application-local.yml | 4 +-
.../filter/RequestLoggingFilterTest.java | 48 +++++++++--
3 files changed, 83 insertions(+), 52 deletions(-)
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 7ea50453..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,24 +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 and responses with truncation suitable for operational debugging.
+ * 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);
@@ -52,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]";
}
@@ -98,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/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml
index 705cedd1..c879825d 100644
--- a/server/skillhub-app/src/main/resources/application-local.yml
+++ b/server/skillhub-app/src/main/resources/application-local.yml
@@ -30,5 +30,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/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: {");
+ }
}
From 024e66d7478b16c1d550584374c6071b73155fb6 Mon Sep 17 00:00:00 2001
From: vsxd
Date: Thu, 19 Mar 2026 15:11:34 +0800
Subject: [PATCH 20/22] feat: add search index rebuild workflow
---
docs/02-domain-model.md | 2 +-
docs/04-search-architecture.md | 2 +-
.../docs/03-user-guide/discovery/search.md | 5 +-
.../current/03-user-guide/discovery/search.md | 5 +-
.../admin/AdminSearchController.java | 52 +++++++
.../admin/AdminSearchControllerTest.java | 82 ++++++++++
.../PostgresSearchRebuildService.java | 140 ++++++++++++++++--
.../PostgresSearchRebuildServiceTest.java | 87 +++++++++++
web/src/api/client.ts | 7 +
web/src/features/governance/use-governance.ts | 6 +
web/src/i18n/locales/en.json | 15 +-
web/src/i18n/locales/zh.json | 15 +-
web/src/pages/admin/audit-log.tsx | 28 ++++
web/src/pages/dashboard/governance.tsx | 50 +++++++
14 files changed, 477 insertions(+), 19 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java
create mode 100644 server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java
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/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/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/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-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 1841a36a..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,35 +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
@@ -61,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) {
@@ -80,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(),
@@ -89,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/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/web/src/api/client.ts b/web/src/api/client.ts
index 5d92ecbc..8cb60e4a 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -831,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 = {
diff --git a/web/src/features/governance/use-governance.ts b/web/src/features/governance/use-governance.ts
index 56a83584..57cfaa4b 100644
--- a/web/src/features/governance/use-governance.ts
+++ b/web/src/features/governance/use-governance.ts
@@ -49,3 +49,9 @@ export function useMarkGovernanceNotificationRead() {
},
})
}
+
+export function useRebuildSearchIndex() {
+ return useMutation({
+ mutationFn: () => governanceApi.rebuildSearchIndex(),
+ })
+}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index e7fe9007..7b2f5794 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -493,6 +493,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...",
@@ -762,7 +765,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",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 0afce248..ff5dcc9a 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -493,6 +493,9 @@
"filterUnhideSkill": "恢复隐藏",
"filterUnarchiveSkill": "恢复归档",
"filterYankVersion": "版本撤回",
+ "filterRebuildSearchIndex": "重建搜索索引",
+ "quickFilterSearchRebuild": "仅看搜索索引重建",
+ "clearFilters": "清空筛选",
"userIdPlaceholder": "用户 ID...",
"requestIdPlaceholder": "请求 ID...",
"ipPlaceholder": "IP 地址...",
@@ -762,7 +765,17 @@
"activityTitle": "治理活动",
"activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。",
"emptyActivity": "暂无治理活动。",
- "unknownActor": "未知操作者"
+ "unknownActor": "未知操作者",
+ "searchMaintenanceTitle": "搜索索引维护",
+ "searchMaintenanceDescription": "重新构建全部技能搜索索引。该操作仅超级管理员可执行,适用于搜索规则变更后的全量回填。",
+ "searchMaintenanceHint": "执行期间可能耗时较长,请避免频繁触发。",
+ "searchRebuildAction": "重建全部搜索索引",
+ "searchRebuildRunning": "重建中...",
+ "searchRebuildConfirmTitle": "确认重建全部搜索索引?",
+ "searchRebuildConfirmDescription": "系统会按当前搜索规则重建所有技能的搜索文档。该操作可能持续一段时间。",
+ "searchRebuildSuccessTitle": "已触发搜索索引重建",
+ "searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。",
+ "searchRebuildErrorTitle": "搜索索引重建失败"
},
"members": {
"title": "成员管理",
diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx
index 1ef81573..a53e9e6a 100644
--- a/web/src/pages/admin/audit-log.tsx
+++ b/web/src/pages/admin/audit-log.tsx
@@ -33,6 +33,7 @@ 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
/**
@@ -68,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 (
@@ -76,6 +95,15 @@ export function AuditLogPage() {
+
+
+ {t('auditLog.quickFilterSearchRebuild')}
+
+
+ {t('auditLog.clearFilters')}
+
+
+
{
setActionFilter(e.target.value)
diff --git a/web/src/pages/dashboard/governance.tsx b/web/src/pages/dashboard/governance.tsx
index db488b05..a259b51c 100644
--- a/web/src/pages/dashboard/governance.tsx
+++ b/web/src/pages/dashboard/governance.tsx
@@ -1,6 +1,10 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { useAuth } from '@/features/auth/use-auth'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
+import { ConfirmDialog } from '@/shared/components/confirm-dialog'
+import { toast } from '@/shared/lib/toast'
+import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { GovernanceInbox } from '@/features/governance/governance-inbox'
@@ -10,6 +14,7 @@ import {
useGovernanceActivity,
useGovernanceInbox,
useGovernanceNotifications,
+ useRebuildSearchIndex,
useGovernanceSummary,
useMarkGovernanceNotificationRead,
} from '@/features/governance/use-governance'
@@ -31,14 +36,28 @@ function SummaryCard({ label, value }: { label: string; value?: number }) {
export function GovernancePage() {
const { t } = useTranslation()
+ const { hasRole } = useAuth()
const [inboxType, setInboxType] = useState('ALL')
+ const [rebuildDialogOpen, setRebuildDialogOpen] = useState(false)
const { data: summary, isLoading: isSummaryLoading } = useGovernanceSummary()
const { data: inboxItems, isLoading: isInboxLoading } = useGovernanceInbox(inboxType === 'ALL' ? undefined : inboxType)
const { data: activityItems, isLoading: isActivityLoading } = useGovernanceActivity()
const { data: notifications, isLoading: isNotificationsLoading } = useGovernanceNotifications()
const markReadMutation = useMarkGovernanceNotificationRead()
+ const rebuildSearchIndexMutation = useRebuildSearchIndex()
const unreadCount = notifications?.filter((item) => item.status === 'UNREAD').length ?? 0
+ const canRebuildSearchIndex = hasRole('SUPER_ADMIN')
+
+ const handleRebuildSearchIndex = async () => {
+ try {
+ await rebuildSearchIndexMutation.mutateAsync()
+ toast.success(t('governance.searchRebuildSuccessTitle'), t('governance.searchRebuildSuccessDescription'))
+ } catch (error) {
+ toast.error(t('governance.searchRebuildErrorTitle'), error instanceof Error ? error.message : '')
+ throw error
+ }
+ }
return (
@@ -101,6 +120,37 @@ export function GovernancePage() {
+
+ {canRebuildSearchIndex ? (
+ <>
+
+
+
{t('governance.searchMaintenanceTitle')}
+
{t('governance.searchMaintenanceDescription')}
+
+
+ setRebuildDialogOpen(true)}
+ disabled={rebuildSearchIndexMutation.isPending}
+ >
+ {rebuildSearchIndexMutation.isPending ? t('governance.searchRebuildRunning') : t('governance.searchRebuildAction')}
+
+ {t('governance.searchMaintenanceHint')}
+
+
+
+
+ >
+ ) : null}
)
}
From 25de227f1b06076f786e2da1a32884c2c6cabace Mon Sep 17 00:00:00 2001
From: vsxd
Date: Thu, 19 Mar 2026 15:11:53 +0800
Subject: [PATCH 21/22] refactor: consolidate backend workflow and security
policies
---
docs/17-backend-annotation-findings.md | 46 ++++
.../portal/NamespaceController.java | 187 ++++++----------
.../portal/SkillLifecycleController.java | 159 ++++----------
.../skillhub/filter/AuthContextFilter.java | 10 +-
.../service/AdminUserManagementService.java | 157 --------------
.../skillhub/service/AuditRequestContext.java | 18 ++
.../NamespacePortalCommandAppService.java | 167 ++++++++++++++
.../NamespacePortalQueryAppService.java | 83 +++++++
.../service/SkillLifecycleAppService.java | 193 +++++++++++++++++
.../NamespacePortalControllerTest.java | 90 ++++++++
.../admin/UserManagementControllerTest.java | 71 ++++++
.../portal/SkillLifecycleControllerTest.java | 73 +++++++
.../filter/AuthContextFilterTest.java | 23 +-
.../NamespacePortalCommandAppServiceTest.java | 74 +++++++
.../NamespacePortalQueryAppServiceTest.java | 70 ++++++
.../service/SkillLifecycleAppServiceTest.java | 76 +++++++
.../skillhub/auth/config/SecurityConfig.java | 101 ++-------
.../policy/RouteSecurityPolicyRegistry.java | 205 ++++++++++++++++++
.../auth/token/ApiTokenScopeService.java | 86 +-------
.../RouteSecurityPolicyRegistryTest.java | 36 +++
.../ApiTokenAuthenticationFilterTest.java | 4 +-
.../auth/token/ApiTokenScopeFilterTest.java | 4 +-
.../auth/token/ApiTokenScopeServiceTest.java | 26 ++-
23 files changed, 1396 insertions(+), 563 deletions(-)
delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuditRequestContext.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java
diff --git a/docs/17-backend-annotation-findings.md b/docs/17-backend-annotation-findings.md
index b4f88640..30986a43 100644
--- a/docs/17-backend-annotation-findings.md
+++ b/docs/17-backend-annotation-findings.md
@@ -2,8 +2,31 @@
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`
@@ -19,8 +42,16 @@ 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`
@@ -39,6 +70,13 @@ 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:
@@ -58,6 +96,8 @@ Suggested direction:
## 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`
@@ -73,6 +113,12 @@ 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:
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 84e2d297..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,19 +2,29 @@ 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;
@@ -26,84 +36,46 @@ import java.util.Map;
@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}")
@@ -111,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")
@@ -127,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")
@@ -157,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")
@@ -207,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}")
@@ -222,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")
@@ -233,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/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java
index 5bee8282..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;
@@ -37,30 +28,12 @@ import org.springframework.web.bind.annotation.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")
@@ -70,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")
@@ -90,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}")
@@ -110,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")
@@ -132,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")
@@ -159,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/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java
index 8c776c77..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;
@@ -34,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
@@ -52,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/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java
deleted file mode 100644
index 6109efeb..00000000
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java
+++ /dev/null
@@ -1,157 +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;
-
-/**
- * Alternative user-management aggregation service that combines user records
- * with role bindings for management-oriented views.
- */
-@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/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/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/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/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/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/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java
index 6958b6db..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;
@@ -58,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,
@@ -67,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;
@@ -77,6 +80,7 @@ public class SecurityConfig {
this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint;
this.apiAccessDeniedHandler = apiAccessDeniedHandler;
this.mockAuthFilterProvider = mockAuthFilterProvider;
+ this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry;
}
/**
@@ -93,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
@@ -110,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))
@@ -229,4 +160,14 @@ public class SecurityConfig {
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/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/token/ApiTokenScopeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java
index cb868267..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,8 +2,8 @@ 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;
@@ -19,47 +19,13 @@ 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) {
@@ -85,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);
@@ -124,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/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());
+ }
}
From e00eb8423431bd9e87ea068150cae69784d6cce7 Mon Sep 17 00:00:00 2001
From: dongmucat <70678707+dongmucat@users.noreply.github.com>
Date: Thu, 19 Mar 2026 15:33:06 +0800
Subject: [PATCH 22/22] chore(web): remove doc injection and add crawler
metadata (#102)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
变更摘要:
- 移除 inject-docs 构建注入链路,build 脚本不再执行文档注入
- 删除注入脚本并移除 marked 依赖,同步更新 pnpm lockfile
- 新增 robots.txt 与 sitemap.xml,显式暴露 registry/skill.md 抓取入口
关键文件:
- web/package.json
- web/pnpm-lock.yaml
- web/scripts/inject-docs.mjs
- web/public/robots.txt
- web/public/sitemap.xml
---
web/package.json | 3 +--
web/pnpm-lock.yaml | 10 ---------
web/public/robots.txt | 3 +++
web/public/sitemap.xml | 9 ++++++++
web/scripts/inject-docs.mjs | 45 -------------------------------------
5 files changed, 13 insertions(+), 57 deletions(-)
create mode 100644 web/public/robots.txt
create mode 100644 web/public/sitemap.xml
delete mode 100644 web/scripts/inject-docs.mjs
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 = `
-
-
- ${htmlContent}
-
-`;
-
-// 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`);