From 383bc1edaed7cbd5ea83959f2e70be7058ba5a43 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Fri, 13 Mar 2026 11:46:21 +0800 Subject: [PATCH] fix(admin): replace compat and admin placeholders with real queries Implement compat search through SkillSearchAppService instead of returning an empty placeholder list, and map search results back to canonical slugs for the compatibility API. Replace hard-coded admin user and audit-log payloads with repository-backed application services. User management now supports paged search and status filters, validates managed statuses and role codes, prevents USER_ADMIN from assigning SUPER_ADMIN, and persists role/status changes against the real repositories. Audit logs now read from the audit_log table through a dedicated query repository/service with filterable pagination. Align admin response DTOs with the frontend contract, add domain not-found handling for localized 404 responses, and cover the new behavior with controller and service regression tests. Verified with targeted skillhub-app tests plus full server mvn test. --- .../compat/ClawHubCompatController.java | 36 ++++- .../controller/admin/AuditLogController.java | 22 +-- .../admin/UserManagementController.java | 26 ++-- .../dto/AdminUserSummaryResponse.java | 11 +- .../skillhub/dto/AuditLogItemResponse.java | 12 +- .../exception/GlobalExceptionHandler.java | 7 + .../repository/AdminUserSearchRepository.java | 74 +++++++++ .../service/AdminAuditLogAppService.java | 97 ++++++++++++ .../skillhub/service/AdminUserAppService.java | 145 +++++++++++++++++ .../src/main/resources/messages.properties | 5 + .../src/main/resources/messages_zh.properties | 5 + .../compat/ClawHubCompatControllerTest.java | 34 +++- .../admin/AuditLogControllerTest.java | 32 +++- .../admin/UserManagementControllerTest.java | 43 ++++- .../service/AdminAuditLogAppServiceTest.java | 44 ++++++ .../service/AdminUserAppServiceTest.java | 147 ++++++++++++++++++ .../repository/UserRoleBindingRepository.java | 4 + .../infra/jpa/UserAccountJpaRepository.java | 3 +- 18 files changed, 700 insertions(+), 47 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index 70717170..de278d66 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -1,28 +1,56 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.service.SkillSearchAppService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/compat/v1") public class ClawHubCompatController { private final CanonicalSlugMapper mapper; + private final SkillSearchAppService skillSearchAppService; - public ClawHubCompatController(CanonicalSlugMapper mapper) { + public ClawHubCompatController(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService) { this.mapper = mapper; + this.skillSearchAppService = skillSearchAppService; } @GetMapping("/search") - public ClawHubSearchResponse search(@RequestParam String q) { - // Return empty results for now (placeholder) - return new ClawHubSearchResponse(List.of()); + public ClawHubSearchResponse search( + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int limit, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + q, + null, + q == null || q.isBlank() ? "newest" : "relevance", + page, + limit, + userId, + userNsRoles + ); + + List items = response.items().stream() + .map(item -> new ClawHubSkillItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.summary(), + item.latestVersion(), + item.starCount())) + .toList(); + + return new ClawHubSearchResponse(items); } @GetMapping("/resolve/{canonicalSlug}") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 4426113f..21fa7405 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -5,19 +5,20 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuditLogItemResponse; import com.iflytek.skillhub.dto.PageResponse; -import org.springframework.data.domain.PageImpl; +import com.iflytek.skillhub.service.AdminAuditLogAppService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.time.Instant; -import java.util.List; - @RestController @RequestMapping("/api/v1/admin/audit-logs") public class AuditLogController extends BaseApiController { - public AuditLogController(ApiResponseFactory responseFactory) { + private final AdminAuditLogAppService adminAuditLogAppService; + + public AuditLogController(AdminAuditLogAppService adminAuditLogAppService, + ApiResponseFactory responseFactory) { super(responseFactory); + this.adminAuditLogAppService = adminAuditLogAppService; } @GetMapping @@ -27,15 +28,6 @@ public class AuditLogController extends BaseApiController { @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String userId, @RequestParam(required = false) String action) { - List logs = List.of( - new AuditLogItemResponse( - "log-1", "user-1", "CREATE_SKILL", "SKILL", "skill-123", Instant.now(), "192.168.1.1" - ), - new AuditLogItemResponse( - "log-2", "user-2", "UPDATE_NAMESPACE", "NAMESPACE", "ns-456", - Instant.now().minusSeconds(3600), "192.168.1.2" - ) - ); - return ok("response.success.read", PageResponse.from(new PageImpl<>(logs))); + return ok("response.success.read", adminAuditLogAppService.listAuditLogs(page, size, userId, action)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 8f4bb657..bffd32c5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; import com.iflytek.skillhub.dto.AdminUserStatusUpdateRequest; @@ -8,39 +9,42 @@ import com.iflytek.skillhub.dto.AdminUserSummaryResponse; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserAppService; import jakarta.validation.Valid; -import org.springframework.data.domain.PageImpl; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/api/v1/admin/users") public class UserManagementController extends BaseApiController { - public UserManagementController(ApiResponseFactory responseFactory) { + private final AdminUserAppService adminUserAppService; + + public UserManagementController(AdminUserAppService adminUserAppService, + ApiResponseFactory responseFactory) { super(responseFactory); + this.adminUserAppService = adminUserAppService; } @GetMapping @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse> listUsers( + @RequestParam(required = false) String search, + @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - List users = List.of( - new AdminUserSummaryResponse("user-1", "alice", "USER", "ACTIVE"), - new AdminUserSummaryResponse("user-2", "bob", "USER", "ACTIVE") - ); - return ok("response.success.read", PageResponse.from(new PageImpl<>(users))); + return ok("response.success.read", adminUserAppService.listUsers(search, status, page, size)); } @PutMapping("/{userId}/role") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse updateUserRole( @PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal, @Valid @RequestBody AdminUserRoleUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, request.role(), null)); + return ok("response.success.updated", + adminUserAppService.updateUserRole(userId, request.role(), principal.platformRoles())); } @PutMapping("/{userId}/status") @@ -48,6 +52,6 @@ public class UserManagementController extends BaseApiController { public ApiResponse updateUserStatus( @PathVariable String userId, @Valid @RequestBody AdminUserStatusUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, null, request.status())); + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status())); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java index 3d569967..3cdbbfd2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java @@ -1,9 +1,14 @@ package com.iflytek.skillhub.dto; +import java.time.LocalDateTime; +import java.util.List; + public record AdminUserSummaryResponse( - String userId, + String id, String username, - String role, - String status + String email, + String status, + List platformRoles, + LocalDateTime createdAt ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java index cd285c8a..699c1b19 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java @@ -3,12 +3,12 @@ package com.iflytek.skillhub.dto; import java.time.Instant; public record AuditLogItemResponse( - String id, - String userId, + Long id, String action, - String resourceType, - String resourceId, - Instant timestamp, - String ipAddress + String userId, + String username, + String details, + String ipAddress, + Instant timestamp ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index c1e91628..49625f63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -43,6 +44,12 @@ public class GlobalExceptionHandler { apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); } + @ExceptionHandler(DomainNotFoundException.class) + public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { String msg = ex.getBindingResult().getFieldErrors().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java new file mode 100644 index 00000000..c68bb7ed --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Repository +public class AdminUserSearchRepository { + + private final EntityManager entityManager; + + public AdminUserSearchRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + public Page search(String search, UserStatus status, Pageable pageable) { + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + + CriteriaQuery query = builder.createQuery(UserAccount.class); + Root root = query.from(UserAccount.class); + List predicates = buildPredicates(search, status, builder, root); + query.select(root) + .where(predicates.toArray(Predicate[]::new)) + .orderBy(builder.desc(root.get("createdAt"))); + + TypedQuery typedQuery = entityManager.createQuery(query); + typedQuery.setFirstResult((int) pageable.getOffset()); + typedQuery.setMaxResults(pageable.getPageSize()); + List users = typedQuery.getResultList(); + + CriteriaQuery countQuery = builder.createQuery(Long.class); + Root countRoot = countQuery.from(UserAccount.class); + List countPredicates = buildPredicates(search, status, builder, countRoot); + countQuery.select(builder.count(countRoot)) + .where(countPredicates.toArray(Predicate[]::new)); + long total = entityManager.createQuery(countQuery).getSingleResult(); + + return new PageImpl<>(users, pageable, total); + } + + private List buildPredicates( + String search, + UserStatus status, + CriteriaBuilder builder, + Root root) { + List predicates = new ArrayList<>(); + if (StringUtils.hasText(search)) { + String normalized = "%" + search.trim().toLowerCase(Locale.ROOT) + "%"; + predicates.add(builder.or( + builder.like(builder.lower(root.get("id")), normalized), + builder.like(builder.lower(root.get("displayName")), normalized), + builder.like(builder.lower(root.get("email")), normalized) + )); + } + if (status != null) { + predicates.add(builder.equal(root.get("status"), status)); + } + return predicates; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java new file mode 100644 index 00000000..d1673617 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -0,0 +1,97 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.PageResponse; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; + +@Service +public class AdminAuditLogAppService { + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + public AdminAuditLogAppService(NamedParameterJdbcTemplate namedParameterJdbcTemplate) { + this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; + } + + @Transactional(readOnly = true) + public PageResponse listAuditLogs(int page, int size, String userId, String action) { + MapSqlParameterSource parameters = new MapSqlParameterSource() + .addValue("limit", size) + .addValue("offset", Math.max(page, 0) * size); + + String whereClause = buildWhereClause(parameters, userId, action); + Long total = namedParameterJdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log al" + whereClause, + parameters, + Long.class + ); + + List items = namedParameterJdbcTemplate.query( + """ + SELECT al.id, + al.action, + al.actor_user_id, + ua.display_name, + al.detail_json, + al.target_type, + al.target_id, + al.client_ip, + al.created_at + FROM audit_log al + LEFT JOIN user_account ua ON ua.id = al.actor_user_id + """ + whereClause + """ + ORDER BY al.created_at DESC + LIMIT :limit OFFSET :offset + """, + parameters, + (rs, rowNum) -> new AuditLogItemResponse( + rs.getLong("id"), + rs.getString("action"), + rs.getString("actor_user_id"), + rs.getString("display_name"), + renderDetails( + rs.getString("detail_json"), + rs.getString("target_type"), + rs.getObject("target_id")), + rs.getString("client_ip"), + toInstant(rs.getTimestamp("created_at"))) + ); + + return new PageResponse<>(items, total == null ? 0 : total, page, size); + } + + private String buildWhereClause(MapSqlParameterSource parameters, String userId, String action) { + StringBuilder clause = new StringBuilder(" WHERE 1 = 1"); + if (StringUtils.hasText(userId)) { + clause.append(" AND al.actor_user_id = :userId"); + parameters.addValue("userId", userId.trim()); + } + if (StringUtils.hasText(action)) { + clause.append(" AND al.action = :action"); + parameters.addValue("action", action.trim()); + } + return clause.toString(); + } + + private String renderDetails(String detailJson, String targetType, Object targetId) { + if (StringUtils.hasText(detailJson)) { + return detailJson; + } + if (!StringUtils.hasText(targetType) && targetId == null) { + return null; + } + return targetType + ":" + targetId; + } + + private Instant toInstant(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toInstant(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java new file mode 100644 index 00000000..5d7a140d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -0,0 +1,145 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.AdminUserMutationResponse; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.repository.AdminUserSearchRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class AdminUserAppService { + + private static final Set MANAGEABLE_STATUSES = Set.of(UserStatus.ACTIVE, UserStatus.DISABLED); + + private final AdminUserSearchRepository adminUserSearchRepository; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final RoleRepository roleRepository; + + public AdminUserAppService( + AdminUserSearchRepository adminUserSearchRepository, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + RoleRepository roleRepository) { + this.adminUserSearchRepository = adminUserSearchRepository; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.roleRepository = roleRepository; + } + + @Transactional(readOnly = true) + public PageResponse listUsers(String search, String status, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = adminUserSearchRepository.search( + search, + StringUtils.hasText(status) ? parseStatus(status) : null, + pageable + ); + Map> rolesByUserId = loadRolesByUserId( + result.getContent().stream().map(UserAccount::getId).toList()); + + List items = result.getContent().stream() + .map(user -> new AdminUserSummaryResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getStatus().name(), + rolesByUserId.getOrDefault(user.getId(), List.of()), + user.getCreatedAt())) + .toList(); + + return new PageResponse<>(items, result.getTotalElements(), result.getNumber(), result.getSize()); + } + + @Transactional + public AdminUserMutationResponse updateUserRole(String userId, String roleCode, Set actorPlatformRoles) { + UserAccount user = loadUser(userId); + String normalizedRoleCode = normalizeRoleCode(roleCode); + + if ("SUPER_ADMIN".equals(normalizedRoleCode) + && (actorPlatformRoles == null || !actorPlatformRoles.contains("SUPER_ADMIN"))) { + throw new DomainForbiddenException("error.admin.user.role.superAdmin.assignDenied"); + } + + userRoleBindingRepository.deleteByUserId(user.getId()); + + if (!"USER".equals(normalizedRoleCode)) { + Role role = roleRepository.findByCode(normalizedRoleCode) + .orElseThrow(() -> new DomainBadRequestException("error.admin.user.role.invalid", roleCode)); + userRoleBindingRepository.save(new UserRoleBinding(user.getId(), role)); + } + + return new AdminUserMutationResponse(user.getId(), normalizedRoleCode, user.getStatus().name()); + } + + @Transactional + public AdminUserMutationResponse updateUserStatus(String userId, String status) { + UserAccount user = loadUser(userId); + UserStatus nextStatus = parseManageableStatus(status); + user.setStatus(nextStatus); + userAccountRepository.save(user); + return new AdminUserMutationResponse(user.getId(), null, nextStatus.name()); + } + + private UserStatus parseManageableStatus(String status) { + UserStatus parsedStatus = parseStatus(status); + if (!MANAGEABLE_STATUSES.contains(parsedStatus)) { + throw new DomainBadRequestException("error.admin.user.status.unsupported"); + } + return parsedStatus; + } + + private UserStatus parseStatus(String status) { + try { + return UserStatus.valueOf(status.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.admin.user.status.invalid", status); + } + } + + private String normalizeRoleCode(String roleCode) { + if (!StringUtils.hasText(roleCode)) { + throw new DomainBadRequestException("error.admin.user.role.invalid", roleCode); + } + return roleCode.trim().toUpperCase(Locale.ROOT); + } + + private Map> loadRolesByUserId(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + return userRoleBindingRepository.findByUserIdIn(userIds).stream() + .collect(Collectors.groupingBy( + UserRoleBinding::getUserId, + Collectors.mapping(binding -> binding.getRole().getCode(), + Collectors.collectingAndThen(Collectors.toList(), + roles -> roles.stream().sorted().toList())))); + } + + private UserAccount loadUser(String userId) { + return userAccountRepository.findById(userId) + .orElseThrow(() -> new DomainNotFoundException("error.admin.user.notFound", userId)); + } +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 7cc2c460..c8b31e6b 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -71,3 +71,8 @@ error.deviceAuth.userCode.invalid=Invalid or expired user code error.deviceAuth.deviceCode.expired=Device code expired error.deviceAuth.deviceCode.invalid=Device code expired or invalid error.deviceAuth.deviceCode.used=Device code has already been used +error.admin.user.notFound=User not found: {0} +error.admin.user.role.invalid=Invalid role: {0} +error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role +error.admin.user.status.invalid=Invalid user status: {0} +error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 5192afd1..9480c9df 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -71,3 +71,8 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码 error.deviceAuth.deviceCode.expired=设备验证码已过期 error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.used=设备验证码已被使用 +error.admin.user.notFound=鐢ㄦ埛涓嶅瓨鍦細{0} +error.admin.user.role.invalid=鏃犳晥鐨勮鑹诧細{0} +error.admin.user.role.superAdmin.assignDenied=鍙湁 SUPER_ADMIN 鍙互鍒嗛厤 SUPER_ADMIN 瑙掕壊 +error.admin.user.status.invalid=鏃犳晥鐨勭敤鎴风姸鎬侊細{0} +error.admin.user.status.unsupported=杩欓噷鍙厑璁告寜 ACTIVE 鎴?DISABLED 绠$悊鐢ㄦ埛鐘舵€? diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 90e6b164..e9c72230 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -15,7 +17,10 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import static org.mockito.Mockito.when; 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; @@ -35,13 +40,38 @@ class ClawHubCompatControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private SkillSearchAppService skillSearchAppService; + @Test - void search_returns_200() throws Exception { + void search_returns_mapped_results() throws Exception { + when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null)) + .thenReturn(new SkillSearchAppService.SearchResponse( + List.of(new SkillSummaryResponse( + 1L, + "my-skill", + "My Skill", + "test summary", + 10L, + 5, + BigDecimal.valueOf(4.5), + 2, + "1.2.0", + "global", + LocalDateTime.of(2026, 3, 13, 9, 0))), + 1, + 0, + 20 + )); + mockMvc.perform(get("/api/compat/v1/search") .param("q", "test")) .andExpect(status().isOk()) .andExpect(jsonPath("$.items").isArray()) - .andExpect(jsonPath("$.items").isEmpty()); + .andExpect(jsonPath("$.items[0].canonicalSlug").value("my-skill")) + .andExpect(jsonPath("$.items[0].description").value("test summary")) + .andExpect(jsonPath("$.items[0].latestVersion").value("1.2.0")) + .andExpect(jsonPath("$.items[0].starCount").value(5)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java index becb9cdd..d255616d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -4,6 +4,9 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminAuditLogAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -17,7 +20,9 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import java.time.Instant; +import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -38,6 +43,9 @@ class AuditLogControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private AdminAuditLogAppService adminAuditLogAppService; + @Test void listAuditLogs_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/audit-logs")) @@ -53,11 +61,27 @@ class AuditLogControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) ); + when(adminAuditLogAppService.listAuditLogs(0, 20, null, null)) + .thenReturn(new PageResponse<>( + List.of(new AuditLogItemResponse( + 1L, + "USER_STATUS_CHANGE", + "user-1", + "alice", + "{\"status\":\"DISABLED\"}", + "127.0.0.1", + Instant.parse("2026-03-13T01:00:00Z"))), + 1, + 0, + 20)); + mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()) - .andExpect(jsonPath("$.data.total").value(2)); + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].username").value("alice")) + .andExpect(jsonPath("$.data.items[0].details").value("{\"status\":\"DISABLED\"}")); } @Test @@ -69,6 +93,9 @@ class AuditLogControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) ); + when(adminAuditLogAppService.listAuditLogs(0, 20, null, null)) + .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); + mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items").isArray()); @@ -83,6 +110,9 @@ class AuditLogControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) ); + when(adminAuditLogAppService.listAuditLogs(0, 20, "user-1", "CREATE_SKILL")) + .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); + mockMvc.perform(get("/api/v1/admin/audit-logs") .param("userId", "user-1") .param("action", "CREATE_SKILL") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index f642cb7c..8379a594 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -4,6 +4,10 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.AdminUserMutationResponse; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -17,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import java.time.LocalDateTime; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -25,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.mockito.Mockito.when; @SpringBootTest @AutoConfigureMockMvc @@ -41,6 +47,9 @@ class UserManagementControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private AdminUserAppService adminUserAppService; + @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) @@ -56,11 +65,27 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); + when(adminUserAppService.listUsers(null, null, 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new AdminUserSummaryResponse( + "user-1", + "alice", + "alice@example.com", + "ACTIVE", + List.of("AUDITOR"), + LocalDateTime.of(2026, 3, 13, 9, 0))), + 1, + 0, + 20)); + mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()) - .andExpect(jsonPath("$.data.total").value(2)); + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value("user-1")) + .andExpect(jsonPath("$.data.items[0].email").value("alice@example.com")) + .andExpect(jsonPath("$.data.items[0].platformRoles[0]").value("AUDITOR")); } @Test @@ -72,6 +97,9 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) ); + when(adminUserAppService.listUsers(null, null, 0, 20)) + .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); + mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items").isArray()); @@ -88,6 +116,9 @@ class UserManagementControllerTest { String requestBody = "{\"role\":\"MODERATOR\"}"; + when(adminUserAppService.updateUserRole("user-123", "MODERATOR", Set.of("USER_ADMIN"))) + .thenReturn(new AdminUserMutationResponse("user-123", "MODERATOR", "ACTIVE")); + mockMvc.perform(put("/api/v1/admin/users/user-123/role") .with(authentication(auth)) .with(csrf()) @@ -96,7 +127,8 @@ class UserManagementControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-123")) - .andExpect(jsonPath("$.data.role").value("MODERATOR")); + .andExpect(jsonPath("$.data.role").value("MODERATOR")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); } @Test @@ -108,7 +140,10 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"status\":\"BANNED\"}"; + String requestBody = "{\"status\":\"DISABLED\"}"; + + when(adminUserAppService.updateUserStatus("user-123", "DISABLED")) + .thenReturn(new AdminUserMutationResponse("user-123", null, "DISABLED")); mockMvc.perform(put("/api/v1/admin/users/user-123/status") .with(authentication(auth)) @@ -118,6 +153,6 @@ class UserManagementControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-123")) - .andExpect(jsonPath("$.data.status").value("BANNED")); + .andExpect(jsonPath("$.data.status").value("DISABLED")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java new file mode 100644 index 00000000..5f2d64cc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.PageResponse; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AdminAuditLogAppServiceTest { + + private final NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class); + private final AdminAuditLogAppService service = new AdminAuditLogAppService(jdbcTemplate); + + @Test + void listAuditLogs_returnsJdbcBackedPage() { + when(jdbcTemplate.queryForObject(contains("COUNT(*)"), any(MapSqlParameterSource.class), eq(Long.class))) + .thenReturn(1L); + when(jdbcTemplate.query(contains("FROM audit_log"), any(MapSqlParameterSource.class), any(RowMapper.class))) + .thenReturn(List.of(new AuditLogItemResponse( + 1L, + "USER_STATUS_CHANGE", + "user-1", + "alice", + "{\"status\":\"DISABLED\"}", + "127.0.0.1", + Instant.parse("2026-03-13T01:00:00Z") + ))); + + PageResponse response = service.listAuditLogs(0, 20, "user-1", "USER_STATUS_CHANGE"); + + assertThat(response.total()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + verify(jdbcTemplate).queryForObject(contains("al.actor_user_id = :userId"), any(MapSqlParameterSource.class), eq(Long.class)); + verify(jdbcTemplate).query(contains("al.action = :action"), any(MapSqlParameterSource.class), any(RowMapper.class)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java new file mode 100644 index 00000000..03b7b853 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -0,0 +1,147 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.repository.AdminUserSearchRepository; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class AdminUserAppServiceTest { + + private final AdminUserSearchRepository adminUserSearchRepository = mock(AdminUserSearchRepository.class); + private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class); + private final RoleRepository roleRepository = mock(RoleRepository.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final AdminUserAppService service = new AdminUserAppService( + adminUserSearchRepository, + userAccountRepository, + userRoleBindingRepository, + roleRepository + ); + + @Test + void listUsers_returnsPagedUsersFromRepository() { + UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE); + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + when(adminUserSearchRepository.search("ali", UserStatus.ACTIVE, pageable)) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userRoleBindingRepository.findByUserIdIn(List.of("user-1"))) + .thenReturn(List.of(new UserRoleBinding("user-1", role("AUDITOR")))); + + PageResponse response = service.listUsers("ali", "ACTIVE", 0, 20); + + assertThat(response.total()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0)).extracting("id", "username", "email", "status") + .containsExactly("user-1", "alice", "alice@example.com", "ACTIVE"); + assertThat(response.items().get(0)).extracting("platformRoles") + .isEqualTo(List.of("AUDITOR")); + } + + @Test + void listUsers_withInvalidStatus_throwsBadRequest() { + assertThrows(DomainBadRequestException.class, () -> service.listUsers(null, "BANNED", 0, 20)); + } + + @Test + void updateUserRole_nonSuperAdminCannotAssignSuperAdmin() { + when(userAccountRepository.findById("user-1")) + .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE))); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN"))); + } + + @Test + void updateUserRole_replacesExistingBindings() { + when(userAccountRepository.findById("user-1")) + .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE))); + when(roleRepository.findByCode("AUDITOR")).thenReturn(Optional.of(role("AUDITOR"))); + + var response = service.updateUserRole("user-1", "AUDITOR", Set.of("SUPER_ADMIN")); + + verify(userRoleBindingRepository).deleteByUserId("user-1"); + verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); + assertThat(response.userId()).isEqualTo("user-1"); + assertThat(response.role()).isEqualTo("AUDITOR"); + assertThat(response.status()).isEqualTo("ACTIVE"); + } + + @Test + void updateUserRole_userPseudoRoleClearsBindingsWithoutSavingNewRole() { + when(userAccountRepository.findById("user-1")) + .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE))); + + var response = service.updateUserRole("user-1", "USER", Set.of("SUPER_ADMIN")); + + verify(userRoleBindingRepository).deleteByUserId("user-1"); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + assertThat(response.role()).isEqualTo("USER"); + } + + @Test + void updateUserStatus_rejectsUnsupportedStatuses() { + when(userAccountRepository.findById("user-1")) + .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE))); + + assertThrows(DomainBadRequestException.class, () -> service.updateUserStatus("user-1", "MERGED")); + } + + @Test + void updateUserStatus_updatesPersistedStatus() { + UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE); + when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.save(user)).thenReturn(user); + + var response = service.updateUserStatus("user-1", "DISABLED"); + + verify(userAccountRepository).save(user); + assertThat(user.getStatus()).isEqualTo(UserStatus.DISABLED); + assertThat(response.status()).isEqualTo("DISABLED"); + } + + @Test + void updateUserStatus_withUnknownUser_throwsNotFound() { + when(userAccountRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(DomainNotFoundException.class, () -> service.updateUserStatus("missing", "DISABLED")); + } + + private UserAccount user(String id, String displayName, String email, UserStatus status) { + UserAccount user = new UserAccount(id, displayName, email, null); + user.setStatus(status); + ReflectionTestUtils.setField(user, "createdAt", LocalDateTime.of(2026, 3, 13, 9, 0)); + ReflectionTestUtils.setField(user, "updatedAt", LocalDateTime.of(2026, 3, 13, 9, 0)); + return user; + } + + private Role role(String code) { + Role role = new Role(); + ReflectionTestUtils.setField(role, "code", code); + ReflectionTestUtils.setField(role, "name", code); + return role; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java index 76aeaf6b..def4ad3b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -3,9 +3,13 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.UserRoleBinding; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; + +import java.util.Collection; import java.util.List; @Repository public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); + List findByUserIdIn(Collection userIds); + void deleteByUserId(String userId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index 4f6e9eb7..c7dcc3ca 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -3,9 +3,10 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository public interface UserAccountJpaRepository - extends JpaRepository, UserAccountRepository { + extends JpaRepository, JpaSpecificationExecutor, UserAccountRepository { }