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.
This commit is contained in:
yun-zhi-ztl 2026-03-13 11:46:21 +08:00
parent ad8bb9c6fd
commit 383bc1edae
18 changed files with 700 additions and 47 deletions

View file

@ -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<Long, NamespaceRole> userNsRoles) {
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
q,
null,
q == null || q.isBlank() ? "newest" : "relevance",
page,
limit,
userId,
userNsRoles
);
List<ClawHubSkillItem> 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}")

View file

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

View file

@ -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<PageResponse<AdminUserSummaryResponse>> listUsers(
@RequestParam(required = false) String search,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
List<AdminUserSummaryResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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()));
}
}

View file

@ -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<String> platformRoles,
LocalDateTime createdAt
) {
}

View file

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

View file

@ -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<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors().stream()

View file

@ -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<UserAccount> search(String search, UserStatus status, Pageable pageable) {
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<UserAccount> query = builder.createQuery(UserAccount.class);
Root<UserAccount> root = query.from(UserAccount.class);
List<Predicate> predicates = buildPredicates(search, status, builder, root);
query.select(root)
.where(predicates.toArray(Predicate[]::new))
.orderBy(builder.desc(root.get("createdAt")));
TypedQuery<UserAccount> typedQuery = entityManager.createQuery(query);
typedQuery.setFirstResult((int) pageable.getOffset());
typedQuery.setMaxResults(pageable.getPageSize());
List<UserAccount> users = typedQuery.getResultList();
CriteriaQuery<Long> countQuery = builder.createQuery(Long.class);
Root<UserAccount> countRoot = countQuery.from(UserAccount.class);
List<Predicate> 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<Predicate> buildPredicates(
String search,
UserStatus status,
CriteriaBuilder builder,
Root<UserAccount> root) {
List<Predicate> 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;
}
}

View file

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

View file

@ -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<UserStatus> 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<AdminUserSummaryResponse> listUsers(String search, String status, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<UserAccount> result = adminUserSearchRepository.search(
search,
StringUtils.hasText(status) ? parseStatus(status) : null,
pageable
);
Map<String, List<String>> rolesByUserId = loadRolesByUserId(
result.getContent().stream().map(UserAccount::getId).toList());
List<AdminUserSummaryResponse> 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<String> 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<String, List<String>> loadRolesByUserId(List<String> 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));
}
}

View file

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

View file

@ -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 绠$悊鐢ㄦ埛鐘舵€?

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<UserRoleBinding, Long> {
List<UserRoleBinding> findByUserId(String userId);
List<UserRoleBinding> findByUserIdIn(Collection<String> userIds);
void deleteByUserId(String userId);
}

View file

@ -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<UserAccount, String>, UserAccountRepository {
extends JpaRepository<UserAccount, String>, JpaSpecificationExecutor<UserAccount>, UserAccountRepository {
}