Complete phase 3 and 4 backend workflows

This commit is contained in:
vsxd 2026-03-13 11:36:34 +08:00
parent 76808ca794
commit 6a9e0845d4
29 changed files with 878 additions and 41 deletions

View file

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

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Void> 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()
);
}

View file

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

View file

@ -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<ResolveVersionResponse> 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<Long, NamespaceRole> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
List<PackageEntry> entries = new ArrayList<>();

View file

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

View file

@ -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<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", adminUserManagementService.listUsers(search, status, page, size));
}
@PutMapping("/{userId}/role")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId);
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
}
}

View file

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

View file

@ -34,4 +34,14 @@ public class MeController extends BaseApiController {
return ok("response.success.read", mySkillAppService.listMySkills(principal.userId()));
}
@GetMapping("/stars")
public ApiResponse<List<SkillSummaryResponse>> listMyStars(
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId()));
}
}

View file

@ -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<PromotionResponseDto> submitPromotion(
@RequestBody PromotionRequestDto request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<PromotionResponseDto> 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<String> 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<PromotionResponseDto> 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<String> 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<PageResponse<PromotionResponseDto>> listPromotions(
@RequestParam(defaultValue = "PENDING") String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
Set<String> 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<PromotionRequest> requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size));
return ok("response.success.read", PageResponse.from(requests.map(this::toResponse)));
}
@GetMapping("/pending")
public ApiResponse<PageResponse<PromotionResponseDto>> 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("\"", "\\\"") + "\"}";
}
}

View file

@ -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<ReviewTaskResponse> submitReview(
@RequestBody ReviewTaskRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
String comment = request != null ? request.comment() : null;
Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
String comment = request != null ? request.comment() : null;
Set<String> 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<Void> 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<PageResponse<ReviewTaskResponse>> 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<Long, NamespaceRole> userNsRoles) {
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
Page<ReviewTask> 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<ReviewTaskResponse> visibleItems = tasks.getContent().stream()
.filter(task -> canViewReview(task, userId, namespaceRoles))
.map(this::toResponse)
.toList();
Page<ReviewTaskResponse> responsePage = new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size());
return ok("response.success.read", PageResponse.from(responsePage));
}
@GetMapping("/pending")
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(
@RequestParam Long namespaceId,
@ -180,4 +227,34 @@ public class ReviewController extends BaseApiController {
task.getReviewedAt()
);
}
private boolean canViewReview(ReviewTask task, String userId, Map<Long, NamespaceRole> 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("\"", "\\\"") + "\"}";
}
}

View file

@ -64,6 +64,9 @@ public class SkillController extends BaseApiController {
detail.status(),
detail.downloadCount(),
detail.starCount(),
detail.ratingAvg(),
detail.ratingCount(),
detail.hidden(),
detail.latestVersion(),
namespace
);

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 username,
String role,
String status
String email,
List<String> platformRoles,
String status,
LocalDateTime createdAt
) {
}

View file

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

View file

@ -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<AdminUserSummaryResponse> listUsers(String keyword, String status, int page, int size) {
UserStatus userStatus = parseStatus(status);
Page<UserAccount> users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size));
List<AdminUserSummaryResponse> 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<UserRoleBinding> 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<String> 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);
}
}
}

View file

@ -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<SkillSummaryResponse> listMySkills(String userId) {
@ -62,6 +67,50 @@ public class MySkillAppService {
.toList();
}
public List<SkillSummaryResponse> listMyStars(String userId) {
List<com.iflytek.skillhub.domain.social.SkillStar> stars = skillStarRepository.findByUserId(
userId,
PageRequest.of(0, 200)
).getContent();
List<Long> skillIds = stars.stream()
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
.distinct()
.toList();
Map<Long, Skill> skillsById = skillIds.isEmpty()
? Map.of()
: skillRepository.findByIdIn(skillIds).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> latestVersionIds = skillsById.values().stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> namespaceIds = skillsById.values().stream()
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, String> 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<Long, SkillVersion> versionsById,

View file

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

View file

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

View file

@ -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.<Long, NamespaceRole>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.<Long, NamespaceRole>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.<Long, NamespaceRole>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())

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ public interface ReviewTaskRepository {
ReviewTask save(ReviewTask reviewTask);
Optional<ReviewTask> findById(Long id);
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
void delete(ReviewTask reviewTask);

View file

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

View file

@ -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<UserAccount> findById(String id);
List<UserAccount> findByIdIn(List<String> ids);
Optional<UserAccount> findByEmailIgnoreCase(String email);
Page<UserAccount> search(String keyword, UserStatus status, Pageable pageable);
UserAccount save(UserAccount user);
}

View file

@ -17,6 +17,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);

View file

@ -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<UserAccount, String>, 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<UserAccount> search(@Param("keyword") String keyword,
@Param("status") UserStatus status,
Pageable pageable);
}