diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 864e5f02..a6c5e2ff 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -23,7 +23,7 @@ services: REDIS_PORT: 6379 SESSION_COOKIE_SECURE: "false" SKILLHUB_PUBLIC_BASE_URL: "http://localhost" - DEVICE_AUTH_VERIFICATION_URI: "http://localhost/api/device/activate" + DEVICE_AUTH_VERIFICATION_URI: "http://localhost/cli/auth" SKILLHUB_STORAGE_PROVIDER: s3 STORAGE_BASE_PATH: /var/lib/skillhub/storage SKILLHUB_STORAGE_S3_ENDPOINT: http://minio:9000 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 6436b8ad..e5f0e391 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 @@ -5,6 +5,7 @@ import com.iflytek.skillhub.compat.dto.ClawHubDeleteResponse; import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse; import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; import com.iflytek.skillhub.compat.dto.ClawHubStarResponse; import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse; @@ -23,8 +24,10 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; 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.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.domain.social.SkillStarService; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.MDC; @@ -55,6 +58,7 @@ public class ClawHubCompatController { private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; private final SkillStarService skillStarService; + private final SkillSlugResolutionService skillSlugResolutionService; public ClawHubCompatController(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService, @@ -66,7 +70,8 @@ public class ClawHubCompatController { SkillRepository skillRepository, NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, - SkillStarService skillStarService) { + SkillStarService skillStarService, + SkillSlugResolutionService skillSlugResolutionService) { this.mapper = mapper; this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; @@ -78,8 +83,10 @@ public class ClawHubCompatController { this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarService = skillStarService; + this.skillSlugResolutionService = skillSlugResolutionService; } + @RateLimit(category = "search", authenticated = 60, anonymous = 20) @GetMapping("/search") public ClawHubSearchResponse search( @RequestParam String q, @@ -125,6 +132,7 @@ public class ClawHubCompatController { return (starScore + downloadScore) / 100.0; } + @RateLimit(category = "resolve", authenticated = 60, anonymous = 20) @GetMapping("/resolve") public ClawHubResolveResponse resolveByQuery( @RequestParam String slug, @@ -160,6 +168,7 @@ public class ClawHubCompatController { return new ClawHubResolveResponse(matchVersion, latestVersion); } + @RateLimit(category = "resolve", authenticated = 60, anonymous = 20) @GetMapping("/resolve/{canonicalSlug}") public ClawHubResolveResponse resolve( @PathVariable String canonicalSlug, @@ -187,6 +196,7 @@ public class ClawHubCompatController { return new ClawHubResolveResponse(matchVersion, latestVersion); } + @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download/{canonicalSlug}") public ResponseEntity downloadByPath(@PathVariable String canonicalSlug, @RequestParam(defaultValue = "latest") String version) { @@ -199,6 +209,7 @@ public class ClawHubCompatController { .build(); } + @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download") public ResponseEntity downloadByQuery(@RequestParam String slug, @RequestParam(defaultValue = "latest") String version) { @@ -215,8 +226,9 @@ public class ClawHubCompatController { .build(); } + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @GetMapping("/skills") - public ClawHubSearchResponse listSkills( + public ClawHubSkillListResponse listSkills( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "25") int limit, @RequestParam(required = false) String sort, @@ -234,13 +246,59 @@ public class ClawHubCompatController { userNsRoles ); - List results = response.items().stream() - .map(this::toSearchResult) + List items = response.items().stream() + .map(this::toSkillListItem) .toList(); - return new ClawHubSearchResponse(results); + // Calculate nextCursor: if there are more results, return next page number as cursor + String nextCursor = null; + long totalResults = response.total(); + long currentOffset = (long) page * limit; + if (currentOffset + items.size() < totalResults) { + nextCursor = String.valueOf(page + 1); + } + + return new ClawHubSkillListResponse(items, nextCursor); } + private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) { + long createdAt = 0; + long updatedAt = item.updatedAt() != null + ? item.updatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + : 0; + + ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null; + if (item.latestVersion() != null) { + latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion( + item.latestVersion(), + updatedAt, // Use skill's updatedAt as version createdAt + "", // changelog not available in summary + null // license not available in summary + ); + } + + // Build stats map with non-null values + Map stats = new java.util.HashMap<>(); + if (item.downloadCount() != null) { + stats.put("downloads", item.downloadCount()); + } + if (item.starCount() != null) { + stats.put("stars", item.starCount()); + } + + return new ClawHubSkillListResponse.SkillListItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + Map.of(), // tags + stats, + createdAt, + updatedAt, + latestVersion + ); + } + + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @GetMapping("/skills/{canonicalSlug}") public ClawHubSkillResponse getSkill( @PathVariable String canonicalSlug, @@ -250,8 +308,7 @@ public class ClawHubCompatController { Namespace ns = namespaceRepository.findBySlug(coord.namespace()) .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); - Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug()) - .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", coord.slug())); + Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), userId); SkillVersion latestVersionEntity = null; if (skill.getLatestVersionId() != null) { @@ -302,6 +359,7 @@ public class ClawHubCompatController { return new ClawHubSkillResponse(skillInfo, versionInfo, ownerInfo, moderationInfo); } + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @DeleteMapping("/skills/{canonicalSlug}") public ClawHubDeleteResponse deleteSkill( @PathVariable String canonicalSlug, @@ -310,6 +368,7 @@ public class ClawHubCompatController { return new ClawHubDeleteResponse(); } + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @PostMapping("/skills/{canonicalSlug}/undelete") public ClawHubDeleteResponse undeleteSkill( @PathVariable String canonicalSlug, @@ -318,6 +377,7 @@ public class ClawHubCompatController { return new ClawHubDeleteResponse(); } + @RateLimit(category = "stars", authenticated = 60, anonymous = 20) @PostMapping("/stars/{canonicalSlug}") public ClawHubStarResponse starSkill( @PathVariable String canonicalSlug, @@ -325,8 +385,7 @@ public class ClawHubCompatController { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); Namespace ns = namespaceRepository.findBySlug(coord.namespace()) .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); - Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug()) - .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug)); + Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId()); boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId()); skillStarService.star(skill.getId(), principal.userId()); @@ -334,6 +393,7 @@ public class ClawHubCompatController { return new ClawHubStarResponse(true, alreadyStarred); } + @RateLimit(category = "stars", authenticated = 60, anonymous = 20) @DeleteMapping("/stars/{canonicalSlug}") public ClawHubUnstarResponse unstarSkill( @PathVariable String canonicalSlug, @@ -341,8 +401,7 @@ public class ClawHubCompatController { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); Namespace ns = namespaceRepository.findBySlug(coord.namespace()) .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); - Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug()) - .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug)); + Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId()); boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId()); skillStarService.unstar(skill.getId(), principal.userId()); @@ -350,6 +409,7 @@ public class ClawHubCompatController { return new ClawHubUnstarResponse(true, alreadyUnstarred); } + @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @PostMapping("/skills") public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson, @RequestParam("files") MultipartFile[] files, @@ -380,6 +440,7 @@ public class ClawHubCompatController { ); } + @RateLimit(category = "publish", authenticated = 60, anonymous = 20) @PostMapping("/publish") public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @@ -413,6 +474,7 @@ public class ClawHubCompatController { return "global"; } + @RateLimit(category = "whoami", authenticated = 60, anonymous = 20) @GetMapping("/whoami") public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { return new ClawHubWhoamiResponse( @@ -421,4 +483,16 @@ public class ClawHubCompatController { principal.avatarUrl() ); } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + try { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED); + } catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException ex) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 3fcaac46..688f9fec 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -1,6 +1,13 @@ package com.iflytek.skillhub.controller; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +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.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuthMeResponse; @@ -28,6 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + @RestController @RequestMapping("/api/v1/auth") public class AuthController extends BaseApiController { @@ -36,25 +46,50 @@ public class AuthController extends BaseApiController { private final SessionBootstrapService sessionBootstrapService; private final DirectAuthService directAuthService; private final AuthFailureThrottleService authFailureThrottleService; + private final UserRoleBindingRepository userRoleBindingRepository; + private final PlatformSessionService platformSessionService; + private final UserAccountRepository userAccountRepository; public AuthController(ApiResponseFactory responseFactory, AuthMethodCatalog authMethodCatalog, SessionBootstrapService sessionBootstrapService, DirectAuthService directAuthService, - AuthFailureThrottleService authFailureThrottleService) { + AuthFailureThrottleService authFailureThrottleService, + UserRoleBindingRepository userRoleBindingRepository, + PlatformSessionService platformSessionService, + UserAccountRepository userAccountRepository) { super(responseFactory); this.authMethodCatalog = authMethodCatalog; this.sessionBootstrapService = sessionBootstrapService; this.directAuthService = directAuthService; this.authFailureThrottleService = authFailureThrottleService; + this.userRoleBindingRepository = userRoleBindingRepository; + this.platformSessionService = platformSessionService; + this.userAccountRepository = userAccountRepository; } @GetMapping("/me") public ApiResponse me(@AuthenticationPrincipal PlatformPrincipal principal, - Authentication authentication) { + Authentication authentication, + HttpServletRequest request) { if (principal == null || authentication == null || !authentication.isAuthenticated()) { throw new UnauthorizedException("error.auth.required"); } + UserAccount user = userAccountRepository.findById(principal.userId()).orElse(null); + if (user == null || user.getStatus() == UserStatus.DISABLED) { + request.getSession().invalidate(); + throw new UnauthorizedException("error.auth.required"); + } + Set freshRoles = PlatformRoleDefaults.withDefaultUserRole( + userRoleBindingRepository.findByUserId(principal.userId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet())); + if (!freshRoles.equals(principal.platformRoles())) { + principal = new PlatformPrincipal( + principal.userId(), principal.displayName(), principal.email(), + principal.avatarUrl(), principal.oauthProvider(), freshRoles); + platformSessionService.establishSession(principal, request, false); + } return ok("response.success.read", AuthMeResponse.from(principal)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index 9bbf57d6..c44b3e35 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.dto.ApiResponse; @@ -21,21 +23,30 @@ import java.util.List; public class TokenController extends BaseApiController { private final ApiTokenService apiTokenService; + private final ObjectMapper objectMapper; - public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory) { + public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory, ObjectMapper objectMapper) { super(responseFactory); this.apiTokenService = apiTokenService; + this.objectMapper = objectMapper; } @PostMapping public ApiResponse create( @AuthenticationPrincipal PlatformPrincipal principal, @Valid @RequestBody TokenCreateRequest request) { - String scopeJson = request.scopes() == null || request.scopes().isEmpty() - ? "[\"skill:read\",\"skill:publish\"]" - : request.scopes().toString(); + String scopeJson; + if (request.scopes() == null || request.scopes().isEmpty()) { + scopeJson = "[\"skill:read\",\"skill:publish\"]"; + } else { + try { + scopeJson = objectMapper.writeValueAsString(request.scopes()); + } catch (JsonProcessingException e) { + scopeJson = "[\"skill:read\",\"skill:publish\"]"; + } + } - var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson, request.expiresAt()); + var result = apiTokenService.rotateToken(principal.userId(), request.name(), scopeJson, request.expiresAt()); return ok("response.success.created", new TokenCreateResponse( result.rawToken(), result.entity().getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index 7bb2964c..d1db37a2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -8,11 +8,11 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; 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.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; @@ -34,29 +34,29 @@ import org.springframework.web.bind.annotation.RestController; public class SkillLifecycleController extends BaseApiController { private final NamespaceRepository namespaceRepository; - private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillGovernanceService skillGovernanceService; private final ReviewService reviewService; private final SkillPublishService skillPublishService; private final AuditLogService auditLogService; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillLifecycleController(NamespaceRepository namespaceRepository, - SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillGovernanceService skillGovernanceService, ReviewService reviewService, SkillPublishService skillPublishService, AuditLogService auditLogService, + SkillSlugResolutionService skillSlugResolutionService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceRepository = namespaceRepository; - this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillGovernanceService = skillGovernanceService; this.reviewService = reviewService; this.skillPublishService = skillPublishService; this.auditLogService = auditLogService; + this.skillSlugResolutionService = skillSlugResolutionService; } @PostMapping("/{namespace}/{slug}/archive") @@ -66,7 +66,7 @@ public class SkillLifecycleController extends BaseApiController { @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { - Skill skill = findSkill(namespace, slug); + Skill skill = findSkill(namespace, slug, userId); Skill archived = skillGovernanceService.archiveSkill( skill.getId(), userId, @@ -86,7 +86,7 @@ public class SkillLifecycleController extends BaseApiController { @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { - Skill skill = findSkill(namespace, slug); + Skill skill = findSkill(namespace, slug, userId); Skill restored = skillGovernanceService.unarchiveSkill( skill.getId(), userId, @@ -106,7 +106,7 @@ public class SkillLifecycleController extends BaseApiController { @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { - Skill skill = findSkill(namespace, slug); + Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); skillGovernanceService.deleteVersion( @@ -128,7 +128,7 @@ public class SkillLifecycleController extends BaseApiController { @PathVariable String version, @RequestAttribute("userId") String userId, HttpServletRequest httpRequest) { - Skill skill = findSkill(namespace, slug); + Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); reviewService.withdrawReview(skillVersion.getId(), userId); @@ -155,7 +155,7 @@ public class SkillLifecycleController extends BaseApiController { @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { - Skill skill = findSkill(namespace, slug); + Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion( @@ -181,11 +181,18 @@ public class SkillLifecycleController extends BaseApiController { new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name())); } - private Skill findSkill(String namespaceSlug, String skillSlug) { + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); - return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java index ca9c5b3a..c6180ef6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java @@ -6,7 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; -import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SkillReportMutationResponse; @@ -24,17 +24,17 @@ import org.springframework.web.bind.annotation.RestController; public class SkillReportController extends BaseApiController { private final NamespaceRepository namespaceRepository; - private final SkillRepository skillRepository; private final SkillReportService skillReportService; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillReportController(NamespaceRepository namespaceRepository, - SkillRepository skillRepository, SkillReportService skillReportService, + SkillSlugResolutionService skillSlugResolutionService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceRepository = namespaceRepository; - this.skillRepository = skillRepository; this.skillReportService = skillReportService; + this.skillSlugResolutionService = skillSlugResolutionService; } @PostMapping("/{namespace}/{slug}/reports") @@ -43,7 +43,7 @@ public class SkillReportController extends BaseApiController { @RequestBody SkillReportSubmitRequest request, @RequestAttribute("userId") String userId, HttpServletRequest httpRequest) { - Skill skill = findSkill(namespace, slug); + Skill skill = findSkill(namespace, slug, userId); var report = skillReportService.submitReport( skill.getId(), userId, @@ -55,11 +55,14 @@ public class SkillReportController extends BaseApiController { return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); } - private Skill findSkill(String namespaceSlug, String skillSlug) { + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); - return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + return skillSlugResolutionService.resolve( + namespace.getId(), + skillSlug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index f338c17d..948e272c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController -@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +@RequestMapping({"/api/web/skills"}) public class SkillSearchController extends BaseApiController { private final SkillSearchAppService skillSearchAppService; @@ -22,7 +22,6 @@ public class SkillSearchController extends BaseApiController { this.skillSearchAppService = skillSearchAppService; } - @GetMapping @RateLimit(category = "search", authenticated = 60, anonymous = 20) public ApiResponse search( @RequestParam(required = false) String q, diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index b8dbc110..c5e36b50 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -4,11 +4,11 @@ server: forward-headers-strategy: framework servlet: session: + timeout: ${SERVER_SERVLET_SESSION_TIMEOUT:8h} cookie: http-only: true secure: ${SESSION_COOKIE_SECURE:false} same-site: lax - max-age: 28800 spring: messages: @@ -98,7 +98,7 @@ skillhub: max-package-size: 104857600 # 100MB allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg device-auth: - verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device} + verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} bootstrap: admin: enabled: ${BOOTSTRAP_ADMIN_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql b/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql new file mode 100644 index 00000000..f1fac58b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql @@ -0,0 +1,6 @@ +-- V12__skill_owner_uniqueness.sql +-- Change skill uniqueness from (namespace_id, slug) to (namespace_id, slug, owner_id) +-- to support owner-isolated skill records with the same name + +ALTER TABLE skill DROP CONSTRAINT skill_namespace_id_slug_key; +ALTER TABLE skill ADD CONSTRAINT skill_namespace_id_slug_owner_id_key UNIQUE(namespace_id, slug, owner_id); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index d1728926..e92a2ae8 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -119,3 +119,5 @@ error.admin.user.role.invalid=Invalid role: {0} error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here +error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace +error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index ba3ad200..d66420e1 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -119,3 +119,5 @@ error.admin.user.role.invalid=无效的角色:{0} error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 +error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 +error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java deleted file mode 100644 index 6523a754..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.iflytek.skillhub.controller; - -import com.iflytek.skillhub.auth.device.DeviceAuthService; -import com.iflytek.skillhub.auth.device.DeviceCodeResponse; -import com.iflytek.skillhub.auth.device.DeviceTokenResponse; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -class DeviceAuthControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private DeviceAuthService deviceAuthService; - - @MockBean - private NamespaceMemberRepository namespaceMemberRepository; - - @Test - void requestDeviceCode_returns_code() throws Exception { - DeviceCodeResponse response = new DeviceCodeResponse( - "device_abc123", - "ABCD-1234", - "https://skillhub.example.com/device", - 900, - 5 - ); - - given(deviceAuthService.generateDeviceCode()).willReturn(response); - - mockMvc.perform(post("/api/v1/auth/device/code") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.deviceCode").value("device_abc123")) - .andExpect(jsonPath("$.data.userCode").value("ABCD-1234")) - .andExpect(jsonPath("$.data.verificationUri").value("https://skillhub.example.com/device")) - .andExpect(jsonPath("$.data.expiresIn").value(900)) - .andExpect(jsonPath("$.data.interval").value(5)); - } - - @Test - void pollToken_returns_pending() throws Exception { - DeviceTokenResponse response = DeviceTokenResponse.pending(); - - given(deviceAuthService.pollToken("device_abc123")).willReturn(response); - - mockMvc.perform(post("/api/v1/auth/device/token") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"deviceCode\": \"device_abc123\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.error").value("authorization_pending")) - .andExpect(jsonPath("$.data.accessToken").isEmpty()) - .andExpect(jsonPath("$.data.tokenType").isEmpty()); - } - - @Test - void pollToken_returns_access_token_when_authorized() throws Exception { - DeviceTokenResponse response = DeviceTokenResponse.success("sk_device_flow_token"); - - given(deviceAuthService.pollToken("device_abc123")).willReturn(response); - - mockMvc.perform(post("/api/v1/auth/device/token") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"deviceCode\": \"device_abc123\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.accessToken").value("sk_device_flow_token")) - .andExpect(jsonPath("$.data.tokenType").value("Bearer")) - .andExpect(jsonPath("$.data.error").isEmpty()); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 26613505..6705325d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -81,7 +81,7 @@ class SkillLifecycleControllerTest { setSkillId(skill, 1L); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); @@ -108,7 +108,7 @@ class SkillLifecycleControllerTest { skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE)); @@ -135,7 +135,7 @@ class SkillLifecycleControllerTest { version.setStatus(SkillVersionStatus.DRAFT); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0") @@ -162,7 +162,7 @@ class SkillLifecycleControllerTest { version.setStatus(SkillVersionStatus.PENDING_REVIEW); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review") @@ -188,7 +188,7 @@ class SkillLifecycleControllerTest { newVersion.setStatus(SkillVersionStatus.PUBLISHED); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner"); setSkillVersionId(sourceVersion, 2L); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java index 4f886b7a..29fab816 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java @@ -64,7 +64,7 @@ class SkillReportControllerTest { ReflectionTestUtils.setField(report, "id", 99L); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class))) .willReturn(report); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index 7a60e0df..e9ac19a5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -32,7 +32,7 @@ public class DeviceAuthService { public DeviceAuthService(RedisTemplate redisTemplate, ApiTokenService apiTokenService, - @Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) { + @Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) { this.redisTemplate = redisTemplate; this.apiTokenService = apiTokenService; this.verificationUri = verificationUri; @@ -109,7 +109,7 @@ public class DeviceAuthService { throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); } - String token = apiTokenService.createToken( + String token = apiTokenService.rotateToken( data.getUserId(), CLI_DEVICE_TOKEN_NAME, CLI_DEVICE_SCOPE_JSON diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index ba7f919f..765c6946 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -15,4 +15,5 @@ public interface ApiTokenRepository extends JpaRepository { List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); Page findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable); boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); + Optional findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(String userId, String name); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index 5e5c2f83..8f24ee3c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -63,6 +63,26 @@ public class ApiTokenService { return new TokenCreateResult(rawToken, token); } + /** + * Revoke existing token with the same name (if any) and create a new one. + * Used by device auth flow to avoid duplicate-name errors on repeated logins. + */ + @Transactional + public TokenCreateResult rotateToken(String userId, String name, String scopeJson) { + return rotateToken(userId, name, scopeJson, null); + } + + @Transactional + public TokenCreateResult rotateToken(String userId, String name, String scopeJson, String expiresAt) { + String normalizedName = normalizeName(name); + tokenRepo.findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(userId, normalizedName) + .ifPresent(existing -> { + existing.setRevokedAt(LocalDateTime.now()); + tokenRepo.save(existing); + }); + return createToken(userId, name, scopeJson, expiresAt); + } + public Optional validateToken(String rawToken) { String hash = sha256(rawToken); return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java deleted file mode 100644 index ac15521a..00000000 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.iflytek.skillhub.auth.device; - -import com.iflytek.skillhub.auth.entity.ApiToken; -import com.iflytek.skillhub.auth.token.ApiTokenService; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeviceAuthServiceTest { - - @Mock - private RedisTemplate redisTemplate; - - @Mock - private ValueOperations valueOperations; - - @Mock - private ApiTokenService apiTokenService; - - private DeviceAuthService service; - - @BeforeEach - void setUp() { - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device"); - } - - @Test - void generateDeviceCode_returns_valid_response() { - // When - DeviceCodeResponse response = service.generateDeviceCode(); - - // Then - assertThat(response.deviceCode()).isNotEmpty(); - assertThat(response.userCode()).matches("[A-Z2-9]{4}-[A-Z2-9]{4}"); - assertThat(response.verificationUri()).isEqualTo("https://skillhub.example.com/device"); - assertThat(response.expiresIn()).isEqualTo(900); // 15 minutes - assertThat(response.interval()).isEqualTo(5); - - // Verify Redis storage - ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(Object.class); - verify(valueOperations, times(2)).set(keyCaptor.capture(), valueCaptor.capture(), eq(15L), eq(TimeUnit.MINUTES)); - - // Verify device code key and data - assertThat(keyCaptor.getAllValues().get(0)).startsWith("device:code:"); - DeviceCodeData data = (DeviceCodeData) valueCaptor.getAllValues().get(0); - assertThat(data.getDeviceCode()).isEqualTo(response.deviceCode()); - assertThat(data.getUserCode()).isEqualTo(response.userCode()); - assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.PENDING); - - // Verify user code key and value - assertThat(keyCaptor.getAllValues().get(1)).startsWith("device:usercode:"); - assertThat(valueCaptor.getAllValues().get(1)).isEqualTo(response.deviceCode()); - } - - @Test - void pollToken_returns_pending_when_not_authorized() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null); - when(valueOperations.get("device:code:device123")).thenReturn(data); - - // When - DeviceTokenResponse response = service.pollToken("device123"); - - // Then - assertThat(response.error()).isEqualTo("authorization_pending"); - assertThat(response.accessToken()).isNull(); - } - - @Test - void pollToken_returns_access_token_when_authorized() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(true); - when(apiTokenService.createToken("42", "CLI Device Flow", "[\"skill:read\",\"skill:publish\"]")) - .thenReturn(new ApiTokenService.TokenCreateResult("sk_cli_token", mock(ApiToken.class))); - - // When - DeviceTokenResponse response = service.pollToken("device123"); - - // Then - assertThat(response.accessToken()).isEqualTo("sk_cli_token"); - assertThat(response.tokenType()).isEqualTo("Bearer"); - assertThat(response.error()).isNull(); - assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.USED); - verify(valueOperations).set("device:code:device123", data, 1L, TimeUnit.MINUTES); - verify(redisTemplate).delete("device:usercode:ABCD-1234"); - } - - @Test - void pollToken_rejects_second_exchange_attempt() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(false); - - // When / Then - assertThatThrownBy(() -> service.pollToken("device123")) - .isInstanceOf(DomainBadRequestException.class) - .hasMessageContaining("error.deviceAuth.deviceCode.used"); - verify(apiTokenService, never()).createToken(anyString(), anyString(), anyString()); - } - - @Test - void pollToken_returns_error_when_expired() { - // Given - when(valueOperations.get("device:code:expired123")).thenReturn(null); - - // When / Then - assertThatThrownBy(() -> service.pollToken("expired123")) - .isInstanceOf(DomainBadRequestException.class) - .hasMessageContaining("error.deviceAuth.deviceCode.invalid"); - } - - @Test - void authorizeDeviceCode_updates_status() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null); - when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - - // When - service.authorizeDeviceCode("ABCD-1234", "42"); - - // Then - assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED); - assertThat(data.getUserId()).isEqualTo("42"); - verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES)); - } - - @Test - void authorizeDeviceCode_rejects_different_user_after_authorization() { - // Given - DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); - when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123"); - when(valueOperations.get("device:code:device123")).thenReturn(data); - - // When / Then - assertThatThrownBy(() -> service.authorizeDeviceCode("ABCD-1234", "99")) - .isInstanceOf(DomainBadRequestException.class) - .hasMessageContaining("error.deviceAuth.deviceCode.alreadyAuthorized"); - } -} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index ec99bbc6..93330f71 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -24,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ConcurrentModificationException; +import java.util.List; import java.util.Map; import java.util.Set; @@ -153,12 +154,30 @@ public class ReviewService { SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + if (skillVersion.getStatus() != SkillVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("review.not_pending", reviewTaskId); + } + + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + + // Check no other owner has a published skill with the same slug + List sameSlugSkills = skillRepository.findByNamespaceIdAndSlug(skill.getNamespaceId(), skill.getSlug()); + for (Skill other : sameSlugSkills) { + if (!other.getId().equals(skill.getId())) { + boolean otherHasPublished = !skillVersionRepository + .findBySkillIdAndStatus(other.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + if (otherHasPublished) { + throw new DomainBadRequestException("error.skill.approve.nameConflict", skill.getSlug()); + } + } + } + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); skillVersion.setPublishedAt(LocalDateTime.now()); skillVersionRepository.save(skillVersion); - Skill skill = skillRepository.findById(skillVersion.getSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); skill.setLatestVersionId(skillVersion.getId()); applyPublishedMetadata(skill, skillVersion); skill.setUpdatedBy(reviewerId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index fa34494c..6bdf2f03 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -7,7 +7,8 @@ public interface SkillRepository { Optional findById(Long id); List findByIdIn(List ids); List findAll(); - Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByNamespaceIdAndSlug(Long namespaceId, String slug); + Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); Skill save(Skill skill); void delete(Skill skill); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 2598a0bf..a57ba4e7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -7,6 +7,9 @@ import java.util.Map; public class VisibilityChecker { public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { + if (skill.getLatestVersionId() == null) { + return isOwner(skill, currentUserId); + } return switch (skill.getVisibility()) { case PUBLIC -> true; case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 8d18b875..490f525b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -26,6 +26,7 @@ public class SkillDownloadService { private final ObjectStorageService objectStorageService; private final VisibilityChecker visibilityChecker; private final ApplicationEventPublisher eventPublisher; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillDownloadService( NamespaceRepository namespaceRepository, @@ -34,7 +35,8 @@ public class SkillDownloadService { SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, VisibilityChecker visibilityChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -42,6 +44,7 @@ public class SkillDownloadService { this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; this.eventPublisher = eventPublisher; + this.skillSlugResolutionService = skillSlugResolutionService; } public record DownloadResult( @@ -59,8 +62,7 @@ public class SkillDownloadService { Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -85,8 +87,7 @@ public class SkillDownloadService { Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -107,8 +108,7 @@ public class SkillDownloadService { Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -155,6 +155,14 @@ public class SkillDownloadService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); + } + private void assertPublishedAccessible(Skill skill) { if (skill.getStatus() != SkillStatus.ACTIVE) { throw new DomainBadRequestException("error.skill.status.notActive"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 24df91c3..5d9ff848 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -188,8 +189,23 @@ public class SkillPublishService { String.join(", ", prePublishValidation.errors())); } - // 6. Find or create Skill record - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + // 6. Find or create Skill record (with owner isolation) + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); + + // Check if any other owner's skill has published versions + for (Skill existing : existingSkills) { + if (!existing.getOwnerId().equals(publisherId)) { + boolean hasPublished = !skillVersionRepository + .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + if (hasPublished) { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } + } + } + + // Find or create skill for current user + Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId) .orElseGet(() -> { Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); newSkill.setCreatedBy(publisherId); @@ -200,6 +216,16 @@ public class SkillPublishService { throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); } + // 6c. Auto-withdraw pending review versions + List pendingVersions = skillVersionRepository + .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW); + for (SkillVersion pending : pendingVersions) { + reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) + .ifPresent(reviewTaskRepository::delete); + pending.setStatus(SkillVersionStatus.DRAFT); + skillVersionRepository.save(pending); + } + // 7. Check version doesn't already exist if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) { throw new DomainBadRequestException("error.skill.version.exists", metadata.version()); 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 32e84fa6..6ef970e8 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 @@ -40,6 +40,7 @@ public class SkillQueryService { private final ObjectStorageService objectStorageService; private final VisibilityChecker visibilityChecker; private final PromotionRequestRepository promotionRequestRepository; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillQueryService( NamespaceRepository namespaceRepository, @@ -49,7 +50,8 @@ public class SkillQueryService { SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, VisibilityChecker visibilityChecker, - PromotionRequestRepository promotionRequestRepository) { + PromotionRequestRepository promotionRequestRepository, + SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -58,6 +60,7 @@ public class SkillQueryService { this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; this.promotionRequestRepository = promotionRequestRepository; + this.skillSlugResolutionService = skillSlugResolutionService; } public record SkillDetailDTO( @@ -114,8 +117,7 @@ public class SkillQueryService { Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -182,7 +184,7 @@ public class SkillQueryService { String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -207,7 +209,7 @@ public class SkillQueryService { String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); @@ -223,7 +225,7 @@ public class SkillQueryService { String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); return skillFileRepository.findByVersionId(skillVersion.getId()); @@ -237,7 +239,7 @@ public class SkillQueryService { String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); @@ -256,7 +258,7 @@ public class SkillQueryService { String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); @@ -269,7 +271,7 @@ public class SkillQueryService { Map userNsRoles, Pageable pageable) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); List visibleVersions; if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { @@ -313,7 +315,7 @@ public class SkillQueryService { } Namespace namespace = findNamespace(namespaceSlug); - Skill skill = findSkill(namespace, skillSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); String fingerprint = computeFingerprint(resolved); @@ -340,14 +342,17 @@ public class SkillQueryService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } - private Skill findSkill(String namespaceSlug, String skillSlug) { + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { Namespace namespace = findNamespace(namespaceSlug); - return findSkill(namespace, skillSlug); + return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); } - private Skill findSkill(Namespace namespace, String skillSlug) { - return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } private SkillVersion findVersion(Skill skill, String version) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java new file mode 100644 index 00000000..d7f9d1bf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class SkillSlugResolutionService { + + public enum Preference { + CURRENT_USER, + PUBLISHED + } + + private final SkillRepository skillRepository; + + public SkillSlugResolutionService(SkillRepository skillRepository) { + this.skillRepository = skillRepository; + } + + public Skill resolve(Long namespaceId, String slug, String currentUserId, Preference preference) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + + Optional ownSkill = currentUserId == null + ? Optional.empty() + : skills.stream().filter(skill -> currentUserId.equals(skill.getOwnerId())).findFirst(); + Optional publishedSkill = skills.stream() + .filter(skill -> skill.getLatestVersionId() != null) + .findFirst(); + + if (preference == Preference.CURRENT_USER) { + return ownSkill.or(() -> publishedSkill) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); + } + + return publishedSkill.or(() -> ownSkill) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index a6589562..07c3fdc0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -23,6 +23,7 @@ public class SkillTagService { private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; private final VisibilityChecker visibilityChecker; + private final SkillSlugResolutionService skillSlugResolutionService; public SkillTagService( NamespaceRepository namespaceRepository, @@ -30,13 +31,15 @@ public class SkillTagService { SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillTagRepository skillTagRepository, - VisibilityChecker visibilityChecker) { + VisibilityChecker visibilityChecker, + SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; this.visibilityChecker = visibilityChecker; + this.skillSlugResolutionService = skillSlugResolutionService; } public List listTags(String namespaceSlug, @@ -44,8 +47,7 @@ public class SkillTagService { String currentUserId, java.util.Map userNamespaceRoles) { Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -76,8 +78,7 @@ public class SkillTagService { Namespace namespace = findNamespace(namespaceSlug); assertAdminOrOwner(namespace.getId(), operatorId); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId); // Find target version SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion) @@ -111,8 +112,7 @@ public class SkillTagService { Namespace namespace = findNamespace(namespaceSlug); assertAdminOrOwner(namespace.getId(), operatorId); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -125,6 +125,14 @@ public class SkillTagService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); + } + private void assertAdminOrOwner(Long namespaceId, String operatorId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId) .map(member -> member.getRole()) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 2ceb4562..ab659faa 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -29,6 +29,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import java.util.ConcurrentModificationException; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -233,6 +234,7 @@ class ReviewServiceTest { .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.approveReview( @@ -263,6 +265,7 @@ class ReviewServiceTest { when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", @@ -316,6 +319,24 @@ class ReviewServiceTest { () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of())); } + @Test + void shouldRejectApproveWhenSkillVersionWasWithdrawnBackToDraft() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + sv.setStatus(SkillVersionStatus.DRAFT); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of())); + } + @Test void shouldThrowWhenNoPermission() { ReviewTask task = createPendingReviewTask(); @@ -356,6 +377,7 @@ class ReviewServiceTest { .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.approveReview( @@ -379,6 +401,33 @@ class ReviewServiceTest { () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of())); } + + @Test + void shouldRejectApproveWhenOtherOwnerHasPublishedSameSlug() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); // owned by USER_ID + + // Another owner's skill with same slug that has a published version + Skill otherSkill = new Skill(NAMESPACE_ID, "my-skill", "other-user", SkillVisibility.PUBLIC); + setField(otherSkill, "id", 99L); + SkillVersion otherPublished = new SkillVersion(99L, "1.0.0", "other-user"); + otherPublished.setStatus(SkillVersionStatus.PUBLISHED); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill, otherSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(99L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(otherPublished)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of())); + } } @Nested diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 4d273345..46d8601b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -14,6 +14,7 @@ class VisibilityCheckerTest { private Skill publicSkill; private Skill namespaceOnlySkill; private Skill privateSkill; + private Skill unpublishedPublicSkill; private static final Long NAMESPACE_ID = 1L; private static final String OWNER_ID = "user-100"; @@ -26,8 +27,12 @@ class VisibilityCheckerTest { checker = new VisibilityChecker(); publicSkill = new Skill(NAMESPACE_ID, "public-skill", OWNER_ID, SkillVisibility.PUBLIC); + publicSkill.setLatestVersionId(10L); namespaceOnlySkill = new Skill(NAMESPACE_ID, "namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY); + namespaceOnlySkill.setLatestVersionId(11L); privateSkill = new Skill(NAMESPACE_ID, "private-skill", OWNER_ID, SkillVisibility.PRIVATE); + privateSkill.setLatestVersionId(12L); + unpublishedPublicSkill = new Skill(NAMESPACE_ID, "draft-public-skill", OWNER_ID, SkillVisibility.PUBLIC); } @Test @@ -99,4 +104,29 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of()); assertFalse(canAccess); } + + @Test + void testUnpublishedSkillNotAccessibleByAnonymousEvenWhenPublic() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, null, Map.of()); + assertFalse(canAccess); + } + + @Test + void testUnpublishedSkillNotAccessibleByOtherUserEvenWhenPublic() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of()); + assertFalse(canAccess); + } + + @Test + void testUnpublishedSkillNotAccessibleByAdmin() { + Map roles = Map.of(NAMESPACE_ID, NamespaceRole.ADMIN); + boolean canAccess = checker.canAccess(unpublishedPublicSkill, ADMIN_USER_ID, roles); + assertFalse(canAccess); + } + + @Test + void testUnpublishedSkillAccessibleByOwner() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OWNER_ID, Map.of()); + assertTrue(canAccess); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index b077f24a..819f0b0f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.lang.reflect.Field; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -45,9 +46,11 @@ class SkillDownloadServiceTest { private ApplicationEventPublisher eventPublisher; private SkillDownloadService service; + private SkillSlugResolutionService skillSlugResolutionService; @BeforeEach void setUp() { + skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); service = new SkillDownloadService( namespaceRepository, skillRepository, @@ -55,7 +58,8 @@ class SkillDownloadServiceTest { skillTagRepository, objectStorageService, visibilityChecker, - eventPublisher + eventPublisher, + skillSlugResolutionService ); } @@ -82,7 +86,7 @@ class SkillDownloadServiceTest { ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); when(objectStorageService.exists(storageKey)).thenReturn(true); @@ -124,7 +128,7 @@ class SkillDownloadServiceTest { ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); @@ -164,7 +168,7 @@ class SkillDownloadServiceTest { ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); when(objectStorageService.exists(storageKey)).thenReturn(true); @@ -196,7 +200,7 @@ class SkillDownloadServiceTest { version.setStatus(SkillVersionStatus.DRAFT); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 01c66828..7987dae0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -109,7 +109,8 @@ class SkillPublishServiceTest { when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -162,7 +163,8 @@ class SkillPublishServiceTest { when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("smoke-skill-two"), eq(publisherId))).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty()); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -205,7 +207,8 @@ class SkillPublishServiceTest { when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("auto-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -253,7 +256,8 @@ class SkillPublishServiceTest { when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(archivedSkill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(archivedSkill)); assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( namespaceSlug, @@ -283,7 +287,8 @@ class SkillPublishServiceTest { when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC))); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC))); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC))); when(skillVersionRepository.findBySkillIdAndVersion(any(), anyString())).thenReturn(Optional.empty()); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -367,7 +372,8 @@ class SkillPublishServiceTest { when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); - when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("admin-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -413,7 +419,8 @@ class SkillPublishServiceTest { Skill skill = new Skill(namespace.getId(), "too-long-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 10L); - when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), "too-long-skill", publisherId)).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.0.0")).thenReturn(Optional.empty()); when(skillVersionRepository.save(any())).thenAnswer(invocation -> { SkillVersion version = invocation.getArgument(0); @@ -537,6 +544,133 @@ class SkillPublishServiceTest { )); } + @Test + void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + // Existing skill owned by another user with a published version + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + + assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of() + )); + } + + @Test + void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + // Existing skill owned by another user with NO published version + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC); + setId(existingSkill, 1L); + + Skill newSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(newSkill, 2L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.empty()); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 10L); + return saved; + }); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of() + ); + + assertNotNull(result); + assertEquals("test-skill", result.slug()); + } + + @Test + void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 2.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of()); + + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + // Existing pending version + SkillVersion pendingV1 = new SkillVersion(1L, "1.0.0", publisherId); + pendingV1.setStatus(SkillVersionStatus.PENDING_REVIEW); + setId(pendingV1, 5L); + ReviewTask pendingTask = new ReviewTask(5L, 1L, publisherId); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of(pendingV1)); + when(reviewTaskRepository.findBySkillVersionIdAndStatus(5L, com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(pendingTask)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("2.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 10L); + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); + + // Verify pending version was withdrawn to DRAFT + assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + verify(reviewTaskRepository).delete(pendingTask); + verify(skillVersionRepository).save(pendingV1); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 79866b58..59d3c432 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -52,9 +52,11 @@ class SkillQueryServiceTest { private PromotionRequestRepository promotionRequestRepository; private SkillQueryService service; + private SkillSlugResolutionService skillSlugResolutionService; @BeforeEach void setUp() { + skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); service = new SkillQueryService( namespaceRepository, skillRepository, @@ -63,7 +65,8 @@ class SkillQueryServiceTest { skillTagRepository, objectStorageService, visibilityChecker, - promotionRequestRepository + promotionRequestRepository, + skillSlugResolutionService ); } @@ -87,7 +90,7 @@ class SkillQueryServiceTest { setId(version, 10L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); @@ -101,6 +104,41 @@ class SkillQueryServiceTest { assertEquals("1.0.0", result.latestVersion()); } + @Test + void testGetSkillDetail_PrefersCurrentUsersOwnSkillOverOtherPublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + + Skill publishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(publishedSkill, 1L); + publishedSkill.setDisplayName("Published Skill"); + publishedSkill.setLatestVersionId(11L); + + Skill ownSkill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(ownSkill, 2L); + ownSkill.setDisplayName("Own Skill"); + ownSkill.setLatestVersionId(22L); + + SkillVersion ownVersion = new SkillVersion(2L, "2.0.0", userId); + setId(ownVersion, 22L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill)); + when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertEquals(2L, result.id()); + assertEquals("Own Skill", result.displayName()); + assertEquals("2.0.0", result.latestVersion()); + } + @Test void testGetSkillDetail_AccessDenied() throws Exception { // Arrange @@ -113,9 +151,10 @@ class SkillQueryServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PRIVATE); setId(skill, 1L); + skill.setLatestVersionId(11L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); // Act & Assert @@ -134,9 +173,10 @@ class SkillQueryServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setLatestVersionId(11L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); assertThrows(DomainForbiddenException.class, () -> service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); @@ -170,6 +210,51 @@ class SkillQueryServiceTest { assertEquals("skill1", result.getContent().get(0).getSlug()); } + @Test + void testGetSkillDetail_ShouldHideOtherUsersUnpublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String viewerId = "user-300"; + Map userNsRoles = Map.of(1L, NamespaceRole.ADMIN); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill unpublishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, viewerId, userNsRoles)); + } + + @Test + void testListSkillsByNamespace_ShouldHideOtherUsersUnpublishedSkills() throws Exception { + String namespaceSlug = "test-ns"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + Pageable pageable = PageRequest.of(0, 10); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill ownUnpublishedSkill = new Skill(1L, "own-skill", userId, SkillVisibility.PUBLIC); + setId(ownUnpublishedSkill, 1L); + Skill othersUnpublishedSkill = new Skill(1L, "other-skill", "user-200", SkillVisibility.PUBLIC); + setId(othersUnpublishedSkill, 2L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) + .thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill)); + when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true); + when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false); + + Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); + + assertEquals(1, result.getTotalElements()); + assertEquals("own-skill", result.getContent().get(0).getSlug()); + } + @Test void testListFiles() throws Exception { // Arrange @@ -189,7 +274,7 @@ class SkillQueryServiceTest { Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); @@ -219,7 +304,7 @@ class SkillQueryServiceTest { skillVersion.setStatus(SkillVersionStatus.DRAFT); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); @@ -246,7 +331,7 @@ class SkillQueryServiceTest { SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); @@ -279,7 +364,7 @@ class SkillQueryServiceTest { skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); @@ -313,7 +398,7 @@ class SkillQueryServiceTest { SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); @@ -351,7 +436,7 @@ class SkillQueryServiceTest { rejected.setStatus(SkillVersionStatus.REJECTED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected)); @@ -385,7 +470,7 @@ class SkillQueryServiceTest { SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of(version100, version110)); @@ -427,7 +512,7 @@ class SkillQueryServiceTest { SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); @@ -460,7 +545,7 @@ class SkillQueryServiceTest { skill.setStatus(SkillStatus.ACTIVE); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -487,7 +572,7 @@ class SkillQueryServiceTest { published.setStatus(SkillVersionStatus.PUBLISHED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); @@ -518,7 +603,7 @@ class SkillQueryServiceTest { published.setStatus(SkillVersionStatus.PUBLISHED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)) @@ -548,7 +633,7 @@ class SkillQueryServiceTest { published.setStatus(SkillVersionStatus.PUBLISHED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); @@ -572,10 +657,16 @@ class SkillQueryServiceTest { Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); setId(skill, 1L); skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -607,7 +698,7 @@ class SkillQueryServiceTest { pending.setStatus(SkillVersionStatus.PENDING_REVIEW); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)) .thenReturn(List.of(pending)); @@ -642,7 +733,7 @@ class SkillQueryServiceTest { pending.setStatus(SkillVersionStatus.PENDING_REVIEW); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); @@ -674,7 +765,7 @@ class SkillQueryServiceTest { pending.setManifestJson("[{\"path\":\"SKILL.md\"}]"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); @@ -709,7 +800,7 @@ class SkillQueryServiceTest { SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); @@ -737,9 +828,10 @@ class SkillQueryServiceTest { SkillVersion pending = new SkillVersion(1L, version, "owner-1"); setId(pending, 11L); pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + skill.setLatestVersionId(10L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); @@ -771,7 +863,7 @@ class SkillQueryServiceTest { rejected.setStatus(SkillVersionStatus.REJECTED); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published)); @@ -803,9 +895,10 @@ class SkillQueryServiceTest { SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); setId(published, 11L); published.setStatus(SkillVersionStatus.PUBLISHED); + skill.setLatestVersionId(11L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java new file mode 100644 index 00000000..8fdbdac1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SkillSlugResolutionServiceTest { + + private final SkillRepository skillRepository = mock(SkillRepository.class); + private final SkillSlugResolutionService service = new SkillSlugResolutionService(skillRepository); + + @Test + void prefersCurrentUsersOwnSkillWhenRequested() throws Exception { + Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L); + Skill ownSkill = createSkill(2L, "demo", "user-1", 22L); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(publishedSkill, ownSkill)); + + Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER); + + assertEquals(2L, resolved.getId()); + } + + @Test + void prefersPublishedSkillForPublicInteractions() throws Exception { + Skill ownDraft = createSkill(2L, "demo", "user-1", null); + Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(ownDraft, publishedSkill)); + + Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.PUBLISHED); + + assertEquals(1L, resolved.getId()); + } + + @Test + void throwsWhenNoSkillMatchesSlug() { + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of()); + + assertThrows(DomainBadRequestException.class, () -> + service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER)); + } + + @Test + void throwsWhenOnlyUnpublishedSkillsBelongToOtherUsers() throws Exception { + Skill otherUsersDraft = createSkill(3L, "demo", "user-2", null); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(otherUsersDraft)); + + assertThrows(DomainBadRequestException.class, () -> + service.resolve(1L, "demo", null, SkillSlugResolutionService.Preference.CURRENT_USER)); + } + + private Skill createSkill(Long id, String slug, String ownerId, Long latestVersionId) throws Exception { + Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC); + Field idField = Skill.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(skill, id); + skill.setLatestVersionId(latestVersionId); + return skill; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 223b24dc..40889385 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -39,16 +39,19 @@ class SkillTagServiceTest { private VisibilityChecker visibilityChecker; private SkillTagService service; + private SkillSlugResolutionService skillSlugResolutionService; @BeforeEach void setUp() { + skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); service = new SkillTagService( namespaceRepository, namespaceMemberRepository, skillRepository, skillVersionRepository, skillTagRepository, - visibilityChecker + visibilityChecker, + skillSlugResolutionService ); } @@ -73,7 +76,7 @@ class SkillTagServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER))); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version)); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty()); when(skillTagRepository.save(any())).thenReturn(tag); @@ -118,7 +121,7 @@ class SkillTagServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN))); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); // Act @@ -175,7 +178,7 @@ class SkillTagServiceTest { SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index cfe57ff3..4a941c24 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -38,10 +38,15 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { } @Override - public Optional findByNamespaceIdAndSlug(Long namespaceId, String slug) { + public List findByNamespaceIdAndSlug(Long namespaceId, String slug) { return delegate.findByNamespaceIdAndSlug(namespaceId, slug); } + @Override + public Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId) { + return delegate.findByNamespaceIdAndSlugAndOwnerId(namespaceId, slug, ownerId); + } + @Override public List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { return delegate.findByNamespaceIdAndStatus(namespaceId, status); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index 6cba15a8..962ae78d 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -18,7 +18,8 @@ import java.util.Optional; @Repository public interface SkillJpaRepository extends JpaRepository, SkillRepository { List findByIdIn(List ids); - Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByNamespaceIdAndSlug(Long namespaceId, String slug); + Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); @Override default List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { diff --git a/web/package.json b/web/package.json index 492b0662..5864c4cb 100644 --- a/web/package.json +++ b/web/package.json @@ -28,14 +28,17 @@ "react-dropzone": "^15.0.0", "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", + "remark-frontmatter": "^5.0.0", "rehype-highlight": "^7.0.2", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^2.2.1", + "unist-util-visit": "^5.0.0", "zustand": "^5.0.11" }, "devDependencies": { + "@types/mdast": "^4.0.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 16dc3788..d86a7f30 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: rehype-sanitize: specifier: ^6.0.0 version: 6.0.0 + remark-frontmatter: + specifier: ^5.0.0 + version: 5.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -65,10 +68,16 @@ importers: tailwind-merge: specifier: ^2.2.1 version: 2.6.1 + unist-util-visit: + specifier: ^5.0.0 + version: 5.1.0 zustand: specifier: ^5.0.11 version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -1362,6 +1371,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1394,6 +1406,10 @@ packages: flatted@3.4.1: resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1654,6 +1670,9 @@ packages: mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -1700,6 +1719,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -2063,6 +2085,9 @@ packages: rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3674,6 +3699,10 @@ snapshots: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -3703,6 +3732,8 @@ snapshots: flatted@3.4.1: {} + format@0.2.2: {} + fraction.js@5.3.4: {} fs.realpath@1.0.0: {} @@ -3970,6 +4001,17 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-gfm-autolink-literal@2.0.1: dependencies: '@types/mdast': 4.0.4 @@ -4120,6 +4162,13 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -4558,6 +4607,15 @@ snapshots: '@types/hast': 3.0.4 hast-util-sanitize: 5.0.2 + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 67422847..c19c5088 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -4,6 +4,12 @@ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { normalizeSearchQuery } from '@/shared/lib/search-query' +// Capture original URL before TanStack Router rewrites it +const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' + +// Export for use in cli-auth page +export { ORIGINAL_URL_SEARCH } + function createLazyRouteComponent>( importer: () => Promise, exportName: keyof TModule, @@ -64,7 +70,7 @@ const PromotionsPage = createLazyRouteComponent( ) const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') -const DeviceAuthPage = createLazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage') +const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), 'SecuritySettingsPage', @@ -269,10 +275,19 @@ const dashboardTokensRoute = createRoute({ component: TokensPage, }) -const deviceRoute = createRoute({ +const cliAuthRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'device', - component: DeviceAuthPage, + path: 'cli/auth', + component: CliAuthPage, + validateSearch: (search: Record): Record => { + // Preserve all CLI auth parameters - use empty string instead of undefined to prevent TanStack Router from removing them + return { + redirect_uri: typeof search.redirect_uri === 'string' ? search.redirect_uri : '', + label_b64: typeof search.label_b64 === 'string' ? search.label_b64 : '', + label: typeof search.label === 'string' ? search.label : '', + state: typeof search.state === 'string' ? search.state : '', + } + }, }) const settingsSecurityRoute = createRoute({ @@ -337,7 +352,7 @@ const routeTree = rootRoute.addChildren([ dashboardPromotionsRoute, dashboardStarsRoute, dashboardTokensRoute, - deviceRoute, + cliAuthRoute, settingsSecurityRoute, settingsAccountsRoute, adminUsersRoute, diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index 3f08b402..45c781bc 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -43,6 +43,7 @@ export function useUpdateUserRole() { updateUserRole(userId, role), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } @@ -54,6 +55,7 @@ export function useUpdateUserStatus() { updateUserStatus(userId, status), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } @@ -64,6 +66,7 @@ export function useApproveUser() { mutationFn: (userId: string) => adminApi.approveUser(userId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } @@ -74,6 +77,7 @@ export function useDisableUser() { mutationFn: (userId: string) => adminApi.disableUser(userId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } @@ -84,6 +88,7 @@ export function useEnableUser() { mutationFn: (userId: string) => adminApi.enableUser(userId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) }, }) } diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts index 24244384..c4f75fe8 100644 --- a/web/src/features/auth/use-account-merge.ts +++ b/web/src/features/auth/use-account-merge.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { accountApi } from '@/api/client' import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' @@ -15,7 +15,11 @@ export function useVerifyAccountMerge() { } export function useConfirmAccountMerge() { + const queryClient = useQueryClient() return useMutation({ mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, }) } diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 5e01e119..c0da8d07 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,13 +1,31 @@ import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' +import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' +import type { Root } from 'mdast' +import { visit } from 'unist-util-visit' interface MarkdownRendererProps { content: string className?: string } +function remarkStripFrontmatter() { + return (tree: Root) => { + visit(tree, (node, index, parent) => { + if (!parent || index === undefined) { + return + } + + const nodeType = String(node.type) + if (nodeType === 'yaml' || nodeType === 'toml') { + parent.children.splice(index, 1) + } + }) + } +} + export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { const containerClassName = [ className, @@ -19,7 +37,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) return (
( diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 8d967f72..81946fa1 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -114,7 +114,18 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD const minDateTime = toLocalDateTimeInputValue(new Date()) return ( - + { + if (nextOpen) { + setCreatedToken(null) + setName('') + setNameError(null) + setExpirationMode('never') + setCustomExpiresAt('') + setExpiresAtError(null) + createMutation.reset() + } + setOpen(nextOpen) + }}> {children} {!createdToken ? ( @@ -210,22 +221,22 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD ) : ( <> - + {t('createToken.successTitle')} - + {t('createToken.successDescription')} -
+
-
+
{createdToken.token}
-
{createdToken.name}
+
{createdToken.name}
diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 561bd5bc..f40b1a6b 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -93,7 +93,7 @@ export function TokenList() { return { previousPages } }, onSuccess: () => { - if (tokenPage && tokenPage.items.length === 1 && page > 0) { + if (tokenPage && tokenPage.items.length === 0 && page > 0) { setPage(page - 1) } setDeleteDialog({ open: false }) @@ -204,7 +204,7 @@ export function TokenList() { {tokens.map((token) => ( - {token.name} + {token.name} {token.tokenPrefix}... @@ -257,9 +257,9 @@ export function TokenList() { setExpirationDialog((current) => ({ ...current, open }))}> - + {t('token.editExpirationTitle')} - + {t('token.editExpirationDescription', { name: expirationDialog.tokenName })} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a72d31ee..2c84ccad 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -213,6 +213,23 @@ "submit": "Authorize Device", "notice": "After authorization, the device will have access to your account" }, + "cliAuth": { + "validating": "Validating...", + "pleaseWait": "Please wait", + "creatingToken": "Creating token...", + "almostThere": "Almost there", + "success": "Authorization successful", + "redirecting": "Redirecting to CLI...", + "fallbackInstructions": "If the browser doesn't redirect automatically, please copy the token below:", + "error": "Authorization failed", + "notAuthenticated": "You are not logged in", + "invalidRedirectUri": "Invalid redirect URI", + "missingState": "Missing security parameter", + "windowsUrlBug": "Known Windows issue: the browser opened an incomplete URL. Please copy the full URL from the CLI output (the line starting with 'Opening browser:') and paste it into your browser address bar.", + "tokenCreationFailed": "Token creation failed", + "loginRequired": "Please log in first to authorize CLI access", + "goToLogin": "Go to Login" + }, "dashboard": { "title": "Dashboard", "subtitle": "View your account, skills, and access credentials in one place", @@ -845,6 +862,7 @@ "visibilityOptions": { "public": "Public", "namespaceOnly": "Namespace Only", + "loggedInUsersOnly": "Logged-in Users Only", "private": "Private" }, "file": "Skill Package File", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 524ecfaf..dbdcea46 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -211,7 +211,24 @@ "defaultError": "授权失败,请检查用户码是否正确", "submitting": "授权中...", "submit": "授权设备", - "notice": "授权后,设备将可以访问你的账户" + "notice": "授权后,设备将可以访问你的账户" + }, + "cliAuth": { + "validating": "验证中...", + "pleaseWait": "请稍候", + "creatingToken": "创建令牌中...", + "almostThere": "马上就好", + "success": "授权成功", + "redirecting": "正在跳转回 CLI...", + "fallbackInstructions": "如果浏览器未自动跳转,请手动复制以下令牌:", + "error": "授权失败", + "notAuthenticated": "您尚未登录", + "invalidRedirectUri": "无效的回调地址", + "missingState": "缺少安全参数", + "windowsUrlBug": "检测到 Windows 系统的已知问题:浏览器打开的 URL 不完整。请从 CLI 输出中复制完整的 URL(以 'Opening browser:' 开头的那一行),然后粘贴到浏览器地址栏中。", + "tokenCreationFailed": "令牌创建失败", + "loginRequired": "请先登录以授权 CLI 访问", + "goToLogin": "前往登录" }, "dashboard": { "title": "Dashboard", @@ -845,6 +862,7 @@ "visibilityOptions": { "public": "公开", "namespaceOnly": "仅命名空间", + "loggedInUsersOnly": "仅登录用户可见", "private": "私有" }, "file": "技能包文件", diff --git a/web/src/index.css b/web/src/index.css index 08ae81a0..bd6e84d5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -85,6 +85,13 @@ code, pre, kbd { font-family: 'JetBrains Mono', ui-monospace, monospace; } + + /* Hide browser-native password reveal button (conflicts with custom toggle) */ + input[type="password"]::-ms-reveal, + input[type="password"]::-ms-clear, + input[type="password"]::-webkit-credentials-auto-fill-button { + display: none; + } } /* ─── Dot-grid background texture ─── */ diff --git a/web/src/pages/cli-auth.tsx b/web/src/pages/cli-auth.tsx new file mode 100644 index 00000000..40336f49 --- /dev/null +++ b/web/src/pages/cli-auth.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' +import { getCurrentUser, tokenApi } from '@/api/client' +import type { User } from '@/api/types' +import { ORIGINAL_URL_SEARCH } from '@/app/router' + +// Parse the original URL params captured before TanStack Router rewrites +const ORIGINAL_PARAMS = new URLSearchParams(ORIGINAL_URL_SEARCH) + +function isValidRedirectUri(uri: string): boolean { + try { + const url = new URL(uri) + // Only allow localhost/127.0.0.1/::1 on HTTP + const validHosts = ['localhost', '127.0.0.1', '[::1]', '::1'] + return url.protocol === 'http:' && validHosts.includes(url.hostname.toLowerCase()) + } catch { + return false + } +} + +function decodeLabel(labelB64?: string, labelPlain?: string): string { + if (labelB64) { + try { + // Base64-URL decode + const base64 = labelB64.replace(/-/g, '+').replace(/_/g, '/') + return atob(base64) + } catch { + // Fallback to plain label + } + } + return labelPlain || 'CLI token' +} + +export function CliAuthPage() { + const { t } = useTranslation() + const navigate = useNavigate() + + const [user, setUser] = useState(undefined) + const [status, setStatus] = useState<'validating' | 'creating' | 'redirecting' | 'error'>('validating') + const [errorMessage, setErrorMessage] = useState('') + const [token, setToken] = useState('') + + // Use the captured original params from module load time + const redirectUri = ORIGINAL_PARAMS.get('redirect_uri')?.trim() || undefined + const state = ORIGINAL_PARAMS.get('state')?.trim() || undefined + const labelB64 = ORIGINAL_PARAMS.get('label_b64')?.trim() || undefined + const labelPlain = ORIGINAL_PARAMS.get('label')?.trim() || undefined + const label = decodeLabel(labelB64, labelPlain) + + // Debug: log search params and raw URL + console.log('CLI Auth - Original search (from router.tsx):', ORIGINAL_URL_SEARCH) + console.log('CLI Auth - Current URL:', typeof window !== 'undefined' ? window.location.href : 'SSR') + console.log('CLI Auth - redirectUri:', redirectUri) + console.log('CLI Auth - state:', state) + console.log('CLI Auth - label:', label) + + useEffect(() => { + // Check authentication status + getCurrentUser() + .then((currentUser) => { + setUser(currentUser) + }) + .catch(() => { + setUser(null) + }) + }, []) + + useEffect(() => { + // Once we know the user status, proceed with token creation + if (user === undefined) { + // Still loading + return + } + + if (user === null) { + // Not authenticated - user needs to log in + setStatus('error') + setErrorMessage(t('cliAuth.notAuthenticated')) + return + } + + // Validate redirect_uri + if (!redirectUri || !isValidRedirectUri(redirectUri)) { + setStatus('error') + setErrorMessage(t('cliAuth.invalidRedirectUri')) + return + } + + // Validate state + if (!state) { + setStatus('error') + // Special error message for Windows users with missing state + if (redirectUri && typeof window !== 'undefined' && navigator.platform.includes('Win')) { + setErrorMessage(t('cliAuth.windowsUrlBug')) + } else { + setErrorMessage(t('cliAuth.missingState')) + } + return + } + + // Create token and redirect + setStatus('creating') + tokenApi + .createToken({ + name: label, + scopes: ['skill:read', 'skill:publish'], + }) + .then((response) => { + setToken(response.token) + setStatus('redirecting') + + // Construct redirect URL with token in hash fragment + const registryUrl = window.location.origin + const hashParams = new URLSearchParams() + hashParams.set('token', response.token) + hashParams.set('registry', registryUrl) + hashParams.set('state', state) + + const redirectUrl = `${redirectUri}#${hashParams.toString()}` + + // Redirect to CLI's loopback server + window.location.assign(redirectUrl) + }) + .catch((error) => { + setStatus('error') + setErrorMessage(error instanceof Error ? error.message : t('cliAuth.tokenCreationFailed')) + }) + }, [user, redirectUri, state, label, t]) + + if (status === 'validating') { + return ( +
+ +
+ + + +
+

{t('cliAuth.validating')}

+

{t('cliAuth.pleaseWait')}

+
+
+ ) + } + + if (status === 'creating') { + return ( +
+ +
+ + + +
+

{t('cliAuth.creatingToken')}

+

{t('cliAuth.almostThere')}

+
+
+ ) + } + + if (status === 'redirecting') { + return ( +
+ +
+ + + +
+

{t('cliAuth.success')}

+

{t('cliAuth.redirecting')}

+ + {token && ( +
+

{t('cliAuth.fallbackInstructions')}

+ + {token} + +
+ )} +
+
+ ) + } + + // Error state + return ( +
+ +
+
+ + + +
+

{t('cliAuth.error')}

+

{errorMessage}

+
+ + {user === null && ( +
+

+ {t('cliAuth.loginRequired')} +

+ +
+ )} +
+
+ ) +} diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index a79036df..b052e984 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -50,6 +50,10 @@ export function PublishPage() { const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() + const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug) + const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL' + ? t('publish.visibilityOptions.loggedInUsersOnly') + : t('publish.visibilityOptions.namespaceOnly') const handleRemoveSelectedFile = () => { setSelectedFile(null) @@ -153,7 +157,7 @@ export function PublishPage() { onChange={(e) => setVisibility(e.target.value)} > - +
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 5a394212..c31c506a 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -135,7 +135,7 @@ export function LoginPage() { onClick={() => setShowPassword((current) => !current)} className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground" > - {showPassword ? : } + {showPassword ? : }
{fieldErrors.password ? ( diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 6f1b45c4..c3cd49d7 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -108,6 +108,7 @@ export function SkillDetailPage() { const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW' const canInteract = skill?.canInteract ?? true + const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' const refreshSkill = () => { queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) @@ -687,7 +688,7 @@ export function SkillDetailPage() { variant="outline" size="lg" onClick={handleDownload} - disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || isPendingPreview} + disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || !isVersionDownloadable} > diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index c61ca284..5da8dea0 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -42,9 +42,9 @@ export function ConfirmDialog({ return ( - + {title} - {description && {description}} + {description && {description}}