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

+ * + * @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: + *

+ * + * @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: + *

+ * + * @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 ? ( +
+ {user.displayName} +
+ ) : null} + + {/* Display name field */} +
+
+ + + {isEditing ? ( + setDisplayName(event.target.value)} + autoFocus + /> + ) : ( +
+ {user?.displayName} + +
+ )} +
+ + {errorMessage ?

{errorMessage}

: null} + + {isEditing ? ( +
+ + +
+ ) : null} +
+ + {/* Email (read-only) */} +
+ +

{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}
) : 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() {
+
+ + +
+