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 ApiResponse me(@AuthenticationPrincipal PlatformPrincipal 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 @@ void meShouldReturnCurrentPrincipal() throws Exception { .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}