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
This commit is contained in:
xiose 2026-03-18 17:53:53 +08:00
parent 012ed35a10
commit 297feb59fc
34 changed files with 1581 additions and 7 deletions

3
.gitignore vendored
View file

@ -68,9 +68,8 @@ __pycache__/
# Superpowers (AI planning artifacts)
docs/superpowers/
docs/review/
docs/requirements/
CLAUDE.md
# oh-my-claudecode
.omc

View file

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

View file

@ -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.
*
* <p>Controls whether machine review and/or human review are enabled
* when users update their profile. Both default to {@code false} (open-source mode).
*
* <p>Configuration combinations:
* <pre>
* 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
* </pre>
*
* <p>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;
}
}

View file

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

View file

@ -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.
*
* <p>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<String, String>> 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<UserProfileResponse> 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.
*
* <p>Depending on moderation configuration, changes may be applied
* immediately or queued for human review.
*/
@PatchMapping
public ApiResponse<UpdateProfileResponse> 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<String, String> 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<String, String> 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<String, String> 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");
}
}
}

View file

@ -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<String, String> changes,
Instant createdAt
) {}

View file

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

View file

@ -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.
*
* <p>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).
*
* <p>Validation rules for displayName:
* <ul>
* <li>Length: 2–32 characters (after trim)</li>
* <li>Allowed characters: Chinese, English, digits, underscore, hyphen</li>
* </ul>
*
* @param displayName new display name (nullable — omit to leave unchanged)
*/
public record UpdateProfileRequest(
@Size(min = 2, max = 32, message = "error.profile.displayName.length")
@Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_-]+$",
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;
}
}

View file

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

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
/**
* Response DTO for GET /api/v1/user/profile.
*
* <p>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
) {}

View file

@ -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.
*
* <p>Always returns {@link ModerationResult#approved()}, meaning profile
* changes take effect immediately without any review.
*
* <p>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<String, String> changes) {
return ModerationResult.approved();
}
}

View file

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

View file

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

View file

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

View file

@ -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=资料变更已提交审核

View file

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

View file

@ -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\":\"<script>alert('xss')</script>\"}"))
.andExpect(status().isBadRequest());
}
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.domain.user;
/**
* Outcome of a profile moderation check.
*
* <p>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
}

View file

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

View file

@ -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.
*
* <p>Each request captures a batch of field changes as JSONB (e.g. displayName, avatarUrl),
* along with the previous values for audit and rollback purposes.
*
* <p>Depending on the moderation configuration, a request may be:
* <ul>
* <li>Immediately approved (no moderation)</li>
* <li>Rejected by machine review</li>
* <li>Queued for human review (PENDING)</li>
* </ul>
*
* @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; }
}

View file

@ -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<ProfileChangeRequest> 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<ProfileChangeRequest> findByUserIdAndStatus(String userId, ProfileChangeStatus status);
}

View file

@ -0,0 +1,25 @@
package com.iflytek.skillhub.domain.user;
/**
* Status of a profile change request throughout its lifecycle.
*
* <p>State transitions:
* <pre>
* 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)
* </pre>
*/
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
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.domain.user;
/**
* Domain-level abstraction for profile moderation configuration.
*
* <p>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();
}

View file

@ -0,0 +1,22 @@
package com.iflytek.skillhub.domain.user;
import java.util.Map;
/**
* Pluggable moderation service for user profile changes.
*
* <p>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<String, String> changes);
}

View file

@ -0,0 +1,28 @@
package com.iflytek.skillhub.domain.user;
/**
* Result of a profile update operation.
*
* <p>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();
}
}

View file

@ -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.
*
* <p>Orchestrates the full update flow: validation → machine review →
* human review (if configured) → apply changes → audit logging.
*
* <p>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).
*
* <p>Depending on moderation config, this may:
* <ul>
* <li>Apply changes immediately (no moderation)</li>
* <li>Reject via machine review</li>
* <li>Queue for human review (PENDING)</li>
* </ul>
*
* @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<String, String> 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<String, String> 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<String, String> 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<String, String> buildOldValues(UserAccount user, Map<String, String> changes) {
Map<String, String> 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<String, String> changes,
Map<String, String> 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);
}
}
}

View file

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

View file

@ -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<ProfileChangeRequest, Long>, ProfileChangeRequestRepository {
@Override
List<ProfileChangeRequest> findByUserIdAndStatus(String userId, ProfileChangeStatus status);
}

View file

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

View file

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

View file

@ -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.",
@ -890,6 +909,7 @@
"users": "User Management",
"auditLog": "Audit Log",
"security": "Security Settings",
"profile": "Profile Settings",
"accounts": "Account Merge",
"logout": "Logout"
}

View file

@ -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": "已启用本地账号密码登录时,可以在这里更新密码。",
@ -890,6 +909,7 @@
"users": "用户管理",
"auditLog": "审计日志",
"security": "安全设置",
"profile": "个人设置",
"accounts": "账号合并",
"logout": "退出登录"
}

View file

@ -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<HTMLFormElement>) {
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 (
<div className="mx-auto max-w-2xl">
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('profile.title')}</CardTitle>
<CardDescription>{t('profile.subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar (read-only for now) */}
{user?.avatarUrl ? (
<div className="flex items-center gap-4">
<img
src={user.avatarUrl}
alt={user.displayName}
className="h-16 w-16 rounded-2xl border-2 border-border/60 shadow-card"
/>
</div>
) : null}
{/* Display name field */}
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="display-name">
{t('profile.displayName')}
</label>
{isEditing ? (
<Input
id="display-name"
type="text"
maxLength={32}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
autoFocus
/>
) : (
<div className="flex items-center gap-3">
<span className="text-sm">{user?.displayName}</span>
<Button type="button" variant="outline" size="sm" onClick={handleEdit}>
{t('profile.edit')}
</Button>
</div>
)}
</div>
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
{isEditing ? (
<div className="flex gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('profile.saving') : t('profile.save')}
</Button>
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
{t('profile.cancel')}
</Button>
</div>
) : null}
</form>
{/* Email (read-only) */}
<div className="space-y-2">
<label className="text-sm font-medium">{t('profile.email')}</label>
<p className="text-sm text-muted-foreground">{user?.email || '-'}</p>
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -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) {
</Link>
) : null}
<div className="-mx-1 my-1 h-px bg-muted" />
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.security')}
<Link to="/settings/profile" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.profile')}
</Link>
{isLocalAccount ? (
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.security')}
</Link>
) : null}
<div className="-mx-1 my-1 h-px bg-muted" />
<button
type="button"