diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java new file mode 100644 index 00000000..1879e54f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java @@ -0,0 +1,100 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Seeds default admin account for Docker one-click startup. + * Idempotent: skips if admin credential already exists. + */ +@Component +@Profile("docker") +public class DockerSeedDataRunner implements ApplicationRunner { + + private static final String ADMIN_USER_ID = "docker-admin"; + private static final String ADMIN_USERNAME = "admin"; + private static final String ADMIN_PASSWORD = "Admin@2026"; + + private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); + + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + + public DockerSeedDataRunner(UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (localCredentialRepository.existsByUsernameIgnoreCase(ADMIN_USERNAME)) { + log.info("Docker seed data already exists, skipping"); + return; + } + + // 1. Create admin user account + UserAccount admin = userAccountRepository.findById(ADMIN_USER_ID) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(ADMIN_USER_ID, "Admin", "admin@skillhub.dev", null) + )); + + // 2. Create local credential (username/password) + localCredentialRepository.save( + new LocalCredential(admin.getId(), ADMIN_USERNAME, passwordEncoder.encode(ADMIN_PASSWORD)) + ); + + // 3. Assign SUPER_ADMIN role + Role superAdmin = roleRepository.findByCode("SUPER_ADMIN") + .orElseThrow(() -> new IllegalStateException("Missing built-in role: SUPER_ADMIN")); + boolean hasRole = userRoleBindingRepository.findByUserId(admin.getId()).stream() + .anyMatch(b -> b.getRole().getCode().equals("SUPER_ADMIN")); + if (!hasRole) { + userRoleBindingRepository.save(new UserRoleBinding(admin.getId(), superAdmin)); + } + + // 4. Ensure global namespace + membership + Namespace globalNs = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(globalNs.getId(), admin.getId()).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); + } + + log.info("Docker seed data initialized — admin account: {} / {}", ADMIN_USERNAME, ADMIN_PASSWORD); + } +} 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..3bbf4a18 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,39 +1,130 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.io.IOException; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Map; +import org.slf4j.MDC; @RestController @RequestMapping("/api/compat/v1") public class ClawHubCompatController { private final CanonicalSlugMapper mapper; + private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; + private final AuditLogService auditLogService; - public ClawHubCompatController(CanonicalSlugMapper mapper) { + public ClawHubCompatController(CanonicalSlugMapper mapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, + AuditLogService auditLogService) { this.mapper = mapper; + this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; + this.auditLogService = auditLogService; } @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, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + var result = skillSearchAppService.search(q, null, "relevance", 0, 20, userId, userNsRoles != null ? userNsRoles : Map.of()); + return new ClawHubSearchResponse(result.items().stream() + .map(item -> new ClawHubSkillItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.summary(), + item.latestVersion(), + item.starCount() + )) + .toList()); } @GetMapping("/resolve/{canonicalSlug}") public ClawHubResolveResponse resolve( @PathVariable String canonicalSlug, - @RequestParam(defaultValue = "latest") String version) { + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + var resolved = skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + null, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); return new ClawHubResolveResponse( canonicalSlug, - version, - "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + resolved.version(), + resolved.downloadUrl() + ); + } + + @GetMapping("/download/{canonicalSlug}") + public ResponseEntity download(@PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + String location = "latest".equals(version) + ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } + + @PostMapping("/publish") + public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, + @RequestParam("namespace") String namespace, + @RequestAttribute("userId") String userId, + jakarta.servlet.http.HttpServletRequest request) throws IOException { + var result = skillPublishService.publishFromEntries( + namespace, + zipPackageExtractor.extract(file), + userId, + SkillVisibility.PUBLIC + ); + auditLogService.record( + userId, + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); + return new ClawHubPublishResponse( + mapper.toCanonical(namespace, result.slug()), + result.version().getVersion(), + result.version().getStatus().name() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java index 9288aba0..7e812f96 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -49,11 +49,23 @@ public class AccountMergeController extends BaseApiController { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - accountMergeService.verifyAndComplete( + accountMergeService.verify( principal.userId(), request.mergeRequestId(), request.verificationToken() ); + return ok("response.success.updated", new MessageResponse("Account merge verified")); + } + + @PostMapping("/confirm") + public ApiResponse confirm(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody ConfirmMergeRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.confirm(principal.userId(), request.mergeRequestId()); return ok("response.success.updated", new MessageResponse("Account merge completed")); } + + public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index cf0273ec..d5b031b1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java @@ -7,7 +7,10 @@ import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.CliWhoamiResponse; +import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillCheckResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.*; @@ -24,11 +27,14 @@ import java.util.zip.ZipInputStream; public class CliController extends BaseApiController { private final SkillPackageValidator skillPackageValidator; + private final SkillQueryService skillQueryService; public CliController(ApiResponseFactory responseFactory, - SkillPackageValidator skillPackageValidator) { + SkillPackageValidator skillPackageValidator, + SkillQueryService skillQueryService) { super(responseFactory); this.skillPackageValidator = skillPackageValidator; + this.skillQueryService = skillQueryService; } @GetMapping("/whoami") @@ -55,6 +61,35 @@ public class CliController extends BaseApiController { return ok("response.success.validated", response); } + @GetMapping("/resolve/{namespace}/{slug}") + public ApiResponse resolve(@PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) java.util.Map userNsRoles) { + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + namespace, + slug, + version, + tag, + hash, + userId, + userNsRoles != null ? userNsRoles : java.util.Map.of() + ); + return ok("response.success.read", new ResolveVersionResponse( + resolved.skillId(), + resolved.namespace(), + resolved.slug(), + resolved.version(), + resolved.versionId(), + resolved.fingerprint(), + resolved.matched(), + resolved.downloadUrl() + )); + } + private List extractZipEntries(MultipartFile file) throws IOException { List entries = new ArrayList<>(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index 13eca9da..aec99cf1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -2,9 +2,12 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.MessageResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -16,18 +19,33 @@ import org.springframework.web.bind.annotation.RestController; public class DeviceAuthWebController extends BaseApiController { private final DeviceAuthService deviceAuthService; + private final AuditLogService auditLogService; - public DeviceAuthWebController(ApiResponseFactory responseFactory, DeviceAuthService deviceAuthService) { + public DeviceAuthWebController(ApiResponseFactory responseFactory, + DeviceAuthService deviceAuthService, + AuditLogService auditLogService) { super(responseFactory); this.deviceAuthService = deviceAuthService; + this.auditLogService = auditLogService; } @PostMapping("/authorize") public ApiResponse authorizeDevice( @RequestBody AuthorizeRequest request, - @AuthenticationPrincipal PlatformPrincipal principal + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest ) { deviceAuthService.authorizeDeviceCode(request.userCode(), principal.userId()); + auditLogService.record( + principal.userId(), + "DEVICE_AUTHORIZE", + "DEVICE_CODE", + null, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"userCode\":\"" + request.userCode() + "\"}" + ); return ok("response.success.updated", new MessageResponse("Device authorized successfully")); } 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..1d6363b4 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,5 +1,6 @@ package com.iflytek.skillhub.controller.admin; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; @@ -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.AdminUserManagementService; 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 AdminUserManagementService adminUserManagementService; + + public UserManagementController(ApiResponseFactory responseFactory, + AdminUserManagementService adminUserManagementService) { super(responseFactory); + this.adminUserManagementService = adminUserManagementService; } @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", adminUserManagementService.listUsers(search, status, page, size)); } @PutMapping("/{userId}/role") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse updateUserRole( @PathVariable String userId, - @Valid @RequestBody AdminUserRoleUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, request.role(), null)); + @Valid @RequestBody AdminUserRoleUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + AdminUserSummaryResponse user = adminUserManagementService.updateUserRole(userId, request.role(), principal); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), request.role(), user.status())); } @PutMapping("/{userId}/status") @@ -48,6 +52,28 @@ 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())); + AdminUserSummaryResponse user = adminUserManagementService.updateUserStatus(userId, request.status()); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/approve") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse approveUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.approveUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/disable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse disableUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.disableUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/enable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse enableUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index e4e0ea7e..c1a743b9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -23,15 +26,18 @@ public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final ZipPackageExtractor zipPackageExtractor; private final SkillHubMetrics skillHubMetrics; + private final AuditLogService auditLogService; public CliPublishController(SkillPublishService skillPublishService, ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + AuditLogService auditLogService) { super(responseFactory); this.skillPublishService = skillPublishService; this.zipPackageExtractor = zipPackageExtractor; this.skillHubMetrics = skillHubMetrics; + this.auditLogService = auditLogService; } @PostMapping("/publish") @@ -40,7 +46,8 @@ public class CliPublishController extends BaseApiController { @RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") String userId) throws IOException { + @RequestAttribute("userId") String userId, + HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -63,6 +70,16 @@ public class CliPublishController extends BaseApiController { publishResult.version().getTotalSize() ); skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); + auditLogService.record( + userId, + "CLI_PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index 9b4c8f75..630cc91c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -34,4 +34,14 @@ public class MeController extends BaseApiController { return ok("response.success.read", mySkillAppService.listMySkills(principal.userId())); } + + @GetMapping("/stars") + public ApiResponse> listMyStars( + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMyStars(principal.userId())); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index eb074de5..f7e541cf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -18,12 +19,14 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.*; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Set; +import org.slf4j.MDC; @RestController @RequestMapping("/api/v1/promotions") @@ -36,6 +39,7 @@ public class PromotionController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; + private final AuditLogService auditLogService; public PromotionController(PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, @@ -44,6 +48,7 @@ public class PromotionController extends BaseApiController { NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.promotionService = promotionService; @@ -53,18 +58,22 @@ public class PromotionController extends BaseApiController { this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; + this.auditLogService = auditLogService; } @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), request.targetNamespaceId(), userId, userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_SUBMIT", userId, promotion.getId(), httpRequest, + "{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"); return ok("response.success.created", toResponse(promotion)); } @@ -72,10 +81,12 @@ public class PromotionController extends BaseApiController { public ApiResponse approvePromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } @@ -83,13 +94,31 @@ public class PromotionController extends BaseApiController { public ApiResponse rejectPromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } + @GetMapping + public ApiResponse> listPromotions( + @RequestParam(defaultValue = "PENDING") String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { + Set platformRoles = rbacService.getUserRoleCodes(userId); + boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); + if (!hasAdminRole) { + throw new DomainForbiddenException("promotion.no_permission"); + } + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); + } + @GetMapping("/pending") public ApiResponse> listPendingPromotions( @RequestParam(defaultValue = "0") int page, @@ -152,4 +181,28 @@ public class PromotionController extends BaseApiController { req.getReviewedAt() ); } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "PROMOTION_REQUEST", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 36429752..6e766132 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -18,12 +19,15 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.*; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Set; +import org.slf4j.MDC; @RestController @RequestMapping("/api/v1/reviews") @@ -36,6 +40,7 @@ public class ReviewController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; + private final AuditLogService auditLogService; public ReviewController(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, @@ -44,6 +49,7 @@ public class ReviewController extends BaseApiController { NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.reviewService = reviewService; @@ -53,19 +59,22 @@ public class ReviewController extends BaseApiController { this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; + this.auditLogService = auditLogService; } @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { ReviewTask task = reviewService.submitReview( request.skillVersionId(), userId, userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId) ); + recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}"); return ok("response.success.created", toResponse(task)); } @@ -74,11 +83,13 @@ public class ReviewController extends BaseApiController { @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.approveReview(id, userId, comment, userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @@ -87,23 +98,59 @@ public class ReviewController extends BaseApiController { @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.rejectReview(id, userId, comment, userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/withdraw") public ApiResponse withdrawReview( @PathVariable Long id, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); reviewService.withdrawReview(task.getSkillVersionId(), userId); + recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}"); return ok("response.success.updated", null); } + @GetMapping + public ApiResponse> listReviews( + @RequestParam String status, + @RequestParam(required = false) Long namespaceId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + + Page tasks; + if (namespaceId != null) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); + } else { + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + } + + java.util.List visibleItems = tasks.getContent().stream() + .filter(task -> canViewReview(task, userId, namespaceRoles)) + .map(this::toResponse) + .toList(); + Page responsePage = new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size()); + return ok("response.success.read", PageResponse.from(responsePage)); + } + @GetMapping("/pending") public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, @@ -180,4 +227,34 @@ public class ReviewController extends BaseApiController { task.getReviewedAt() ); } + + private boolean canViewReview(ReviewTask task, String userId, Map namespaceRoles) { + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + return reviewService.canViewReview(task, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId)); + } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "REVIEW_TASK", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 90dde642..e265ce57 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -64,6 +64,9 @@ public class SkillController extends BaseApiController { detail.status(), detail.downloadCount(), detail.starCount(), + detail.ratingAvg(), + detail.ratingCount(), + detail.hidden(), detail.latestVersion(), namespace ); 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..3412fb1a 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 username, - String role, - String status + String email, + List platformRoles, + String status, + LocalDateTime createdAt ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 6bdafbf6..12d0019a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.dto; +import java.math.BigDecimal; + public record SkillDetailResponse( Long id, String slug, @@ -9,6 +11,9 @@ public record SkillDetailResponse( String status, Long downloadCount, Integer starCount, + BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, String namespace ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java new file mode 100644 index 00000000..7be258b7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +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.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AdminUserManagementService { + + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final RoleRepository roleRepository; + + public AdminUserManagementService(UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + RoleRepository roleRepository) { + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.roleRepository = roleRepository; + } + + @Transactional(readOnly = true) + public PageResponse listUsers(String keyword, String status, int page, int size) { + UserStatus userStatus = parseStatus(status); + Page users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size)); + List items = users.getContent().stream() + .map(this::toSummary) + .toList(); + return PageResponse.from(new PageImpl<>(items, users.getPageable(), users.getTotalElements())); + } + + @Transactional + public AdminUserSummaryResponse updateUserRole(String userId, String roleCode, PlatformPrincipal principal) { + UserAccount user = loadUser(userId); + if (principal != null + && !principal.platformRoles().contains("SUPER_ADMIN") + && "SUPER_ADMIN".equalsIgnoreCase(roleCode)) { + throw new DomainForbiddenException("error.admin.role.assign_super_admin_forbidden"); + } + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new DomainBadRequestException("error.role.notFound", roleCode)); + + List existing = userRoleBindingRepository.findByUserId(userId); + boolean alreadyAssigned = existing.stream().anyMatch(binding -> binding.getRole().getCode().equals(roleCode)); + if (!alreadyAssigned) { + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } + return toSummary(user); + } + + @Transactional + public AdminUserSummaryResponse approveUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse updateUserStatus(String userId, String status) { + UserAccount user = loadUser(userId); + user.setStatus(parseRequiredStatus(status)); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse disableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.DISABLED); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse enableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + private UserAccount loadUser(String userId) { + return userAccountRepository.findById(userId) + .orElseThrow(() -> new DomainNotFoundException("error.user.notFound", userId)); + } + + private AdminUserSummaryResponse toSummary(UserAccount user) { + Set roles = new LinkedHashSet<>(); + userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .sorted(Comparator.naturalOrder()) + .forEach(roles::add); + return new AdminUserSummaryResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + List.copyOf(roles), + user.getStatus().name(), + user.getCreatedAt() + ); + } + + private String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } + + private UserStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return parseRequiredStatus(status); + } + + private UserStatus parseRequiredStatus(String status) { + try { + return UserStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.user.status.invalid", status); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index b262621f..6f2684f0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -5,8 +5,10 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; import org.springframework.stereotype.Service; +import org.springframework.data.domain.PageRequest; import java.util.Comparator; import java.util.List; @@ -21,14 +23,17 @@ public class MySkillAppService { private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillStarRepository skillStarRepository; public MySkillAppService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + SkillVersionRepository skillVersionRepository, + SkillStarRepository skillStarRepository) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; + this.skillStarRepository = skillStarRepository; } public List listMySkills(String userId) { @@ -62,6 +67,50 @@ public class MySkillAppService { .toList(); } + public List listMyStars(String userId) { + List stars = skillStarRepository.findByUserId( + userId, + PageRequest.of(0, 200) + ).getContent(); + + List skillIds = stars.stream() + .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) + .distinct() + .toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List latestVersionIds = skillsById.values().stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = latestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List namespaceIds = skillsById.values().stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespaceSlugsById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap( + com.iflytek.skillhub.domain.namespace.Namespace::getId, + com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + + return stars.stream() + .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) + .map(star -> skillsById.get(star.getSkillId())) + .filter(java.util.Objects::nonNull) + .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .toList(); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1c0274c9..59782dab 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -100,5 +100,10 @@ error.auth.merge.pendingExists=A pending merge request already exists for this s error.auth.merge.localCredentialConflict=Both accounts already have local credentials error.auth.merge.requestNotFound=Merge request not found error.auth.merge.requestNotPending=Merge request is not pending +error.auth.merge.requestNotVerified=Merge request is not verified error.auth.merge.tokenExpired=Merge verification token has expired error.auth.merge.invalidToken=Invalid merge verification token +error.admin.role.assign_super_admin_forbidden=Only SUPER_ADMIN can assign the SUPER_ADMIN role +error.role.notFound=Role not found: {0} +error.user.notFound=User not found: {0} +error.user.status.invalid=Invalid user status: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index fda7b01c..e2a62d34 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -100,5 +100,10 @@ error.auth.merge.pendingExists=该待合并账号已有进行中的合并请求 error.auth.merge.localCredentialConflict=两个账号都已启用本地密码登录,无法自动合并 error.auth.merge.requestNotFound=未找到合并请求 error.auth.merge.requestNotPending=该合并请求不处于待验证状态 +error.auth.merge.requestNotVerified=该合并请求尚未完成验证 error.auth.merge.tokenExpired=合并验证 token 已过期 error.auth.merge.invalidToken=合并验证 token 无效 +error.admin.role.assign_super_admin_forbidden=只有 SUPER_ADMIN 才能分配 SUPER_ADMIN 角色 +error.role.notFound=角色不存在:{0} +error.user.notFound=用户不存在:{0} +error.user.status.invalid=非法的用户状态:{0} 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..aa93b433 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,9 @@ 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.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +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; @@ -14,8 +17,12 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Map; import java.util.Set; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -35,8 +42,17 @@ class ClawHubCompatControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private SkillSearchAppService skillSearchAppService; + + @MockBean + private SkillQueryService skillQueryService; + @Test void search_returns_200() throws Exception { + given(skillSearchAppService.search("test", null, "relevance", 0, 20, null, Map.of())) + .willReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + mockMvc.perform(get("/api/compat/v1/search") .param("q", "test")) .andExpect(status().isOk()) @@ -46,6 +62,25 @@ class ClawHubCompatControllerTest { @Test void resolve_returns_correct_downloadUrl() throws Exception { + given(skillQueryService.resolveVersion( + eq("global"), + eq("my-skill"), + isNull(), + eq("latest"), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "global", + "my-skill", + "latest", + 1L, + "sha256:test", + true, + "/api/v1/skills/global/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) @@ -55,6 +90,25 @@ class ClawHubCompatControllerTest { @Test void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { + given(skillQueryService.resolveVersion( + eq("team-ai"), + eq("my-skill"), + isNull(), + eq("latest"), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "team-ai", + "my-skill", + "latest", + 1L, + "sha256:test", + true, + "/api/v1/skills/team-ai/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) @@ -64,6 +118,25 @@ class ClawHubCompatControllerTest { @Test void resolve_with_version_returns_specified_version() throws Exception { + given(skillQueryService.resolveVersion( + eq("global"), + eq("my-skill"), + eq("1.0.0"), + isNull(), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "global", + "my-skill", + "1.0.0", + 2L, + "sha256:test", + true, + "/api/v1/skills/global/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/my-skill") .param("version", "1.0.0")) .andExpect(status().isOk()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java index 167c0f1e..7d0afcd6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; 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.post; @@ -73,6 +74,27 @@ class AccountMergeControllerTest { """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge verified")); + + verify(accountMergeService).verify("usr_primary", 1L, "merge-token"); + } + + @Test + void confirm_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.message").value("Account merge completed")); + + verify(accountMergeService).confirm("usr_primary", 1L); } } 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..3ec1746d 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,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.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserManagementService; 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.LocalDateTime; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -41,6 +46,9 @@ class UserManagementControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private AdminUserManagementService adminUserManagementService; + @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) @@ -49,6 +57,17 @@ class UserManagementControllerTest { @Test void listUsers_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.listUsers(null, null, 0, 20)) + .willReturn(new PageResponse<>( + List.of( + new AdminUserSummaryResponse("user-1", "alice", "alice@example.com", List.of("USER"), "ACTIVE", LocalDateTime.parse("2026-03-12T12:00:00")), + new AdminUserSummaryResponse("user-2", "bob", "bob@example.com", List.of("USER_ADMIN"), "PENDING", LocalDateTime.parse("2026-03-12T13:00:00")) + ), + 2, + 0, + 20 + )); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -65,6 +84,14 @@ class UserManagementControllerTest { @Test void listUsers_withSuperAdminRole_returns200() throws Exception { + given(adminUserManagementService.listUsers(null, null, 0, 20)) + .willReturn(new PageResponse<>( + List.of(new AdminUserSummaryResponse("user-99", "superadmin", "super@example.com", List.of("SUPER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T14:00:00"))), + 1, + 0, + 20 + )); + PlatformPrincipal principal = new PlatformPrincipal( "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") ); @@ -79,6 +106,9 @@ class UserManagementControllerTest { @Test void updateUserRole_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.updateUserRole(org.mockito.ArgumentMatchers.eq("user-123"), org.mockito.ArgumentMatchers.eq("USER_ADMIN"), org.mockito.ArgumentMatchers.any())) + .willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T15:00:00"))); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -86,7 +116,7 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"role\":\"MODERATOR\"}"; + String requestBody = "{\"role\":\"USER_ADMIN\"}"; mockMvc.perform(put("/api/v1/admin/users/user-123/role") .with(authentication(auth)) @@ -96,11 +126,14 @@ 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("USER_ADMIN")); } @Test void updateUserStatus_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.updateUserStatus("user-123", "DISABLED")) + .willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER"), "DISABLED", LocalDateTime.parse("2026-03-12T16:00:00"))); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -108,7 +141,7 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"status\":\"BANNED\"}"; + String requestBody = "{\"status\":\"DISABLED\"}"; mockMvc.perform(put("/api/v1/admin/users/user-123/status") .with(authentication(auth)) @@ -118,6 +151,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-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 01fa1032..660e4c3c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -78,7 +78,8 @@ public class SecurityConfig { "/swagger-ui/**", "/.well-known/**", "/api/compat/v1/search", - "/api/compat/v1/resolve/**" + "/api/compat/v1/resolve/**", + "/api/compat/v1/download/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/skills", diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java index c1c96a19..b671f60b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java @@ -14,6 +14,7 @@ import java.time.LocalDateTime; public class AccountMergeRequest { public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_VERIFIED = "VERIFIED"; public static final String STATUS_COMPLETED = "COMPLETED"; public static final String STATUS_CANCELLED = "CANCELLED"; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index 7a571bc5..9badccb5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -100,7 +100,7 @@ public class AccountMergeService { } @Transactional - public void verifyAndComplete(String primaryUserId, Long mergeRequestId, String verificationToken) { + public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) { AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { @@ -113,6 +113,23 @@ public class AccountMergeService { throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); } + loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(loadActiveUser(primaryUserId), secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + mergeRequestRepository.save(request); + } + + @Transactional + public void confirm(String primaryUserId, Long mergeRequestId) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified"); + } + UserAccount primaryUser = loadActiveUser(primaryUserId); UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); 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..ceb753c3 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 @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import java.util.Collection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -8,4 +9,6 @@ import java.util.List; @Repository public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); + List findByUserIdIn(Collection userIds); + long deleteByUserId(String userId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 9dfd545c..9d3a3c49 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -8,6 +8,7 @@ public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0b127f28..e4953ebf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -61,6 +61,9 @@ public class SkillQueryService { String status, Long downloadCount, Integer starCount, + java.math.BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, Long namespaceId ) {} @@ -120,6 +123,9 @@ public class SkillQueryService { skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), + skill.getRatingAvg(), + skill.getRatingCount(), + skill.isHidden(), latestVersion, skill.getNamespaceId() ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index fdd8f21a..d2163c56 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -1,9 +1,15 @@ package com.iflytek.skillhub.domain.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; import java.util.Optional; public interface UserAccountRepository { Optional findById(String id); + List findByIdIn(List ids); Optional findByEmailIgnoreCase(String email); + Page search(String keyword, UserStatus status, Pageable pageable); UserAccount save(UserAccount user); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 570dd992..5ff37a7e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -17,6 +17,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); 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..d5a907be 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 @@ -2,10 +2,31 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserAccountJpaRepository extends JpaRepository, UserAccountRepository { + + @Override + @Query(""" + SELECT u + FROM UserAccount u + WHERE (:status IS NULL OR u.status = :status) + AND ( + :keyword IS NULL + OR lower(u.displayName) LIKE lower(concat('%', :keyword, '%')) + OR lower(coalesce(u.email, '')) LIKE lower(concat('%', :keyword, '%')) + OR lower(u.id) LIKE lower(concat('%', :keyword, '%')) + ) + """) + Page search(@Param("keyword") String keyword, + @Param("status") UserStatus status, + Pageable pageable); }