Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,8 @@ __pycache__/
# Superpowers (AI planning artifacts)
docs/superpowers/
docs/review/
docs/requirements/
CLAUDE.md

# oh-my-claudecode
.omc


Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,20 @@ public ApiResponse<AuthMeResponse> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Loading
Loading