From 3ab76b201a36cf349c961a2872102e0dcbd2a041 Mon Sep 17 00:00:00 2001 From: tww <1451548858@qq.com> Date: Mon, 16 Mar 2026 17:33:12 +0800 Subject: [PATCH 01/23] cli login fix --- .env.release.draft | 2 +- docker-compose.staging.yml | 2 +- .../compat/ClawHubCompatController.java | 69 +++++- .../skillhub/controller/TokenController.java | 21 +- .../portal/SkillSearchController.java | 3 +- .../src/main/resources/application.yml | 2 +- .../controller/DeviceAuthControllerTest.java | 4 +- .../auth/device/DeviceAuthService.java | 4 +- .../auth/repository/ApiTokenRepository.java | 1 + .../skillhub/auth/token/ApiTokenService.java | 20 ++ .../auth/device/DeviceAuthServiceTest.java | 8 +- web/src/app/router.tsx | 25 +- web/src/i18n/locales/en.json | 17 ++ web/src/i18n/locales/zh.json | 19 +- web/src/pages/cli-auth.tsx | 223 ++++++++++++++++++ 15 files changed, 392 insertions(+), 28 deletions(-) create mode 100644 web/src/pages/cli-auth.tsx diff --git a/.env.release.draft b/.env.release.draft index 8a0c28e2..c8c01d27 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -13,7 +13,7 @@ SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web # Public HTTPS entrypoint, no trailing slash. -SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com +SKILLHUB_PUBLIC_BASE_URL=https://skill.xfyun.cn # Usually keep empty when web and api are served from the same domain. SKILLHUB_WEB_API_BASE_URL= 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..ef8a4f31 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; @@ -25,6 +26,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; 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; @@ -80,6 +82,7 @@ public class ClawHubCompatController { this.skillStarService = skillStarService; } + @RateLimit(category = "search", authenticated = 60, anonymous = 20) @GetMapping("/search") public ClawHubSearchResponse search( @RequestParam String q, @@ -125,6 +128,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 +164,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 +192,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 +205,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 +222,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 +242,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, @@ -302,6 +356,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 +365,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 +374,7 @@ public class ClawHubCompatController { return new ClawHubDeleteResponse(); } + @RateLimit(category = "stars", authenticated = 60, anonymous = 20) @PostMapping("/stars/{canonicalSlug}") public ClawHubStarResponse starSkill( @PathVariable String canonicalSlug, @@ -334,6 +391,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, @@ -350,6 +408,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 +439,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 +473,7 @@ public class ClawHubCompatController { return "global"; } + @RateLimit(category = "whoami", authenticated = 60, anonymous = 20) @GetMapping("/whoami") public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { return new ClawHubWhoamiResponse( 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/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 f999efe3..91a53856 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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:true} 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 index 6523a754..068e81b9 100644 --- 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 @@ -37,7 +37,7 @@ class DeviceAuthControllerTest { DeviceCodeResponse response = new DeviceCodeResponse( "device_abc123", "ABCD-1234", - "https://skillhub.example.com/device", + "https://skill.xfyun.cn/cli/auth", 900, 5 ); @@ -50,7 +50,7 @@ class DeviceAuthControllerTest { .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.verificationUri").value("https://skill.xfyun.cn/cli/auth")) .andExpect(jsonPath("$.data.expiresIn").value(900)) .andExpect(jsonPath("$.data.interval").value(5)); } 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 index ac15521a..9be6a82d 100644 --- 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 @@ -36,7 +36,7 @@ class DeviceAuthServiceTest { @BeforeEach void setUp() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); - service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device"); + service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skill.xfyun.cn/cli/auth"); } @Test @@ -47,7 +47,7 @@ class DeviceAuthServiceTest { // 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.verificationUri()).isEqualTo("https://skill.xfyun.cn/cli/auth"); assertThat(response.expiresIn()).isEqualTo(900); // 15 minutes assertThat(response.interval()).isEqualTo(5); @@ -88,7 +88,7 @@ class DeviceAuthServiceTest { 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\"]")) + when(apiTokenService.rotateToken("42", "CLI Device Flow", "[\"skill:read\",\"skill:publish\"]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_cli_token", mock(ApiToken.class))); // When @@ -114,7 +114,7 @@ class DeviceAuthServiceTest { assertThatThrownBy(() -> service.pollToken("device123")) .isInstanceOf(DomainBadRequestException.class) .hasMessageContaining("error.deviceAuth.deviceCode.used"); - verify(apiTokenService, never()).createToken(anyString(), anyString(), anyString()); + verify(apiTokenService, never()).rotateToken(anyString(), anyString(), anyString()); } @Test diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 0d9af6f3..b6a80f85 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -3,6 +3,12 @@ import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' +// 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, @@ -62,7 +68,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', @@ -260,10 +266,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({ @@ -327,7 +342,7 @@ const routeTree = rootRoute.addChildren([ dashboardPromotionsRoute, dashboardStarsRoute, dashboardTokensRoute, - deviceRoute, + cliAuthRoute, settingsSecurityRoute, settingsAccountsRoute, adminUsersRoute, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2c92c08e..d5e5fab6 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": "Manage your account and API Tokens", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c115779c..f23cd7aa 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", 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')} +

+ +
+ )} +
+
+ ) +} From 84bc5c7d9d7d7c92cf533ed6dbd67b14605977ef Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <66589705+yun-zhi-ztl@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:29:26 +0800 Subject: [PATCH 02/23] fix: improve dashboard UX and session refresh handling (#51) * fix: refresh skill download counts after download * fix: limit skill search query length * fix: truncate long error messages in ui * fix: refresh auth roles promptly * fix: block disabled users with active sessions * fix: add my skills preview to dashboard * fix: align dashboard my skills layout * fix: refine dashboard my skills preview * fix: adjust dashboard my skills grid * fix: keep dashboard more tile visible * fix: refine dashboard copy tone --- .../skillhub/filter/AuthContextFilter.java | 30 +++++- .../filter/AuthContextFilterTest.java | 92 +++++++++++++++++++ web/src/api/client.ts | 40 ++++++++ web/src/app/router.tsx | 3 +- web/src/features/auth/use-auth.test.ts | 15 +++ web/src/features/auth/use-auth.ts | 17 +++- web/src/features/search/search-bar.tsx | 2 + web/src/i18n/locales/en.json | 18 ++-- web/src/i18n/locales/zh.json | 14 ++- web/src/pages/dashboard-preview.test.ts | 28 ++++++ web/src/pages/dashboard-preview.ts | 14 +++ web/src/pages/dashboard.tsx | 71 +++++++++++++- web/src/pages/device.tsx | 3 +- web/src/pages/home.tsx | 3 +- web/src/pages/landing.tsx | 3 +- web/src/pages/search.tsx | 7 +- web/src/pages/settings/accounts.tsx | 13 ++- web/src/pages/settings/security.tsx | 5 +- web/src/pages/skill-detail.tsx | 34 ++++++- web/src/shared/hooks/use-skill-queries.ts | 4 +- web/src/shared/lib/error-display.test.ts | 14 +++ web/src/shared/lib/error-display.ts | 18 ++++ web/src/shared/lib/search-query.test.ts | 15 +++ web/src/shared/lib/search-query.ts | 5 + .../shared/lib/skill-download-cache.test.ts | 85 +++++++++++++++++ web/src/shared/lib/skill-download-cache.ts | 77 ++++++++++++++++ web/src/shared/lib/toast.ts | 6 +- 27 files changed, 600 insertions(+), 36 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java create mode 100644 web/src/features/auth/use-auth.test.ts create mode 100644 web/src/pages/dashboard-preview.test.ts create mode 100644 web/src/pages/dashboard-preview.ts create mode 100644 web/src/shared/lib/error-display.test.ts create mode 100644 web/src/shared/lib/error-display.ts create mode 100644 web/src/shared/lib/search-query.test.ts create mode 100644 web/src/shared/lib/search-query.ts create mode 100644 web/src/shared/lib/skill-download-cache.test.ts create mode 100644 web/src/shared/lib/skill-download-cache.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 60d7ac6d..aeced23f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -4,15 +4,18 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Map; import java.util.stream.Collectors; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -20,9 +23,12 @@ import org.springframework.web.filter.OncePerRequestFilter; public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; - public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository) { + public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; } @Override @@ -32,6 +38,11 @@ public class AuthContextFilter extends OncePerRequestFilter { FilterChain filterChain) throws ServletException, IOException { PlatformPrincipal principal = resolvePrincipal(request); if (principal != null) { + if (isInactiveUser(principal.userId())) { + clearAuthentication(request); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } request.setAttribute("userId", principal.userId()); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( @@ -44,6 +55,23 @@ public class AuthContextFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + private boolean isInactiveUser(String userId) { + return userAccountRepository.findById(userId) + .map(user -> !user.isActive()) + .orElse(true); + } + + private void clearAuthentication(HttpServletRequest request) { + SecurityContextHolder.clearContext(); + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + session.removeAttribute("platformPrincipal"); + session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + session.invalidate(); + } + private PlatformPrincipal resolvePrincipal(HttpServletRequest request) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java new file mode 100644 index 00000000..e6050ca4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.filter; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthContextFilterTest { + + private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final AuthContextFilter filter = new AuthContextFilter(namespaceMemberRepository, userAccountRepository); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-1", "Alice", "alice@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("Alice", "alice@example.com"); + user.setStatus(UserStatus.DISABLED); + + MockHttpServletRequest request = new MockHttpServletRequest(); + HttpSession session = request.getSession(true); + session.setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-1")).thenReturn(java.util.Optional.of(user)); + + filter.doFilter(request, response, filterChain); + + assertEquals(401, response.getStatus()); + assertTrue(!request.isRequestedSessionIdValid() || request.getSession(false) == null); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void activeSessionUser_shouldPopulateRequestContextAndContinue() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-2", "Bob", "bob@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("Bob", "bob@example.com"); + user.setStatus(UserStatus.ACTIVE); + NamespaceMember member = new NamespaceMember(9L, "user-2", NamespaceRole.ADMIN); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true).setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-2")).thenReturn(java.util.Optional.of(user)); + when(namespaceMemberRepository.findByUserId("user-2")).thenReturn(List.of(member)); + + filter.doFilter(request, response, filterChain); + + assertEquals("user-2", request.getAttribute("userId")); + assertEquals(NamespaceRole.ADMIN, ((java.util.Map) request.getAttribute("userNsRoles")).get(9L)); + verify(filterChain).doFilter(request, response); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4e08c3fa..bb0da6d9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,6 +37,11 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' +export type DownloadedFile = { + blob: Blob + fileName?: string +} + type RuntimeConfig = { apiBaseUrl?: string appBaseUrl?: string @@ -273,6 +278,20 @@ function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } +function parseDownloadFileName(contentDisposition: string | null): string | undefined { + if (!contentDisposition) { + return undefined + } + + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match) { + return decodeURIComponent(utf8Match[1]) + } + + const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) + return basicMatch?.[1] +} + export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me', { @@ -422,6 +441,27 @@ export const accountApi = { }, } +export const skillDownloadApi = { + async downloadVersion(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + const response = await fetch( + withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), + { + headers: withRequestHeaders(), + }, + ) + + if (!response.ok) { + throw new ApiError(`HTTP ${response.status}`, response.status) + } + + return { + blob: await response.blob(), + fileName: parseDownloadFileName(response.headers.get('content-disposition')), + } + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 63783b87..67422847 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, type ComponentType } from 'react' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' import { Layout } from './layout' import { getCurrentUser } from '@/api/client' +import { normalizeSearchQuery } from '@/shared/lib/search-query' function createLazyRouteComponent>( importer: () => Promise, @@ -139,7 +140,7 @@ const searchRoute = createRoute({ component: SearchPage, validateSearch: (search: Record) => { return { - q: (search.q as string) || '', + q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', diff --git a/web/src/features/auth/use-auth.test.ts b/web/src/features/auth/use-auth.test.ts new file mode 100644 index 00000000..49bb858a --- /dev/null +++ b/web/src/features/auth/use-auth.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getAuthQueryOptions } from './use-auth' + +describe('getAuthQueryOptions', () => { + it('keeps auth state fresh so role changes are picked up promptly', () => { + const options = getAuthQueryOptions(true) + + expect(options.queryKey).toEqual(['auth', 'me']) + expect(options.staleTime).toBe(0) + expect(options.refetchOnWindowFocus).toBe(true) + expect(options.refetchOnReconnect).toBe(true) + expect(options.refetchInterval).toBe(60_000) + expect(options.enabled).toBe(true) + }) +}) diff --git a/web/src/features/auth/use-auth.ts b/web/src/features/auth/use-auth.ts index 54fbf84f..4a9d66d5 100644 --- a/web/src/features/auth/use-auth.ts +++ b/web/src/features/auth/use-auth.ts @@ -2,14 +2,21 @@ import { useQuery } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { User } from '@/api/types' -export function useAuth(enabled = true) { - const { data: user, isLoading, error } = useQuery({ - queryKey: ['auth', 'me'], +export function getAuthQueryOptions(enabled = true) { + return { + queryKey: ['auth', 'me'] as const, queryFn: authApi.getMe, retry: false, enabled, - staleTime: 5 * 60 * 1000, // 5 分钟 - }) + staleTime: 0, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: 60_000, + } +} + +export function useAuth(enabled = true) { + const { data: user, isLoading, error } = useQuery(getAuthQueryOptions(enabled)) return { user: user ?? null, diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 9331ba0a..32a3bb49 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, Search, X } from 'lucide-react' +import { MAX_SEARCH_QUERY_LENGTH } from '@/shared/lib/search-query' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -52,6 +53,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching = type="text" value={currentQuery} onChange={(e) => handleChange(e.target.value)} + maxLength={MAX_SEARCH_QUERY_LENGTH} placeholder={placeholder || t('searchBar.placeholder')} className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" /> diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 150a292c..a72d31ee 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -215,21 +215,27 @@ }, "dashboard": { "title": "Dashboard", - "subtitle": "Manage your account and API Tokens", + "subtitle": "View your account, skills, and access credentials in one place", "backToDashboard": "Back to Dashboard", - "userInfo": "User Info", - "userInfoDesc": "Your account details", + "userInfo": "Account Information", + "userInfoDesc": "Basic account details and platform roles", "loginVia": "Logged in via {{provider}}", "platformRoles": "Platform Roles", "starsAndRatings": "Stars & Ratings", "viewStars": "View My Stars", + "mySkillsTitle": "My Skills", + "openMySkills": "View My Skills", + "mySkillsPreviewDescription": "Showing your 5 most recent skills. Open a skill or go to My Skills to view all.", + "mySkillsPreviewEmpty": "You have not published any skills yet", "credentials": "Credentials", - "openTokens": "Open Token Page", + "openTokens": "View API Tokens", "governanceTitle": "Review & Governance", - "viewGovernance": "Open governance center", + "viewGovernance": "Open Governance Center", "viewPromotions": "View Promotions", "reportsTitle": "Report Management", - "viewReports": "View skill reports" + "viewReports": "View skill reports", + "previewMore": "...", + "previewMoreLabel": "View All" }, "mySkills": { "title": "My Skills", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 6602a769..524ecfaf 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -215,21 +215,27 @@ }, "dashboard": { "title": "Dashboard", - "subtitle": "管理你的账户和 API Tokens", + "subtitle": "统一查看账户信息、技能资产与访问凭证", "backToDashboard": "返回控制台", "userInfo": "用户信息", - "userInfoDesc": "你的账户详情", + "userInfoDesc": "查看当前账户的基础信息与平台角色", "loginVia": "通过 {{provider}} 登录", "platformRoles": "平台角色", "starsAndRatings": "收藏与评分", "viewStars": "查看我的收藏", + "mySkillsTitle": "我的技能", + "openMySkills": "查看我的技能", + "mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往“我的技能”查看全部。", + "mySkillsPreviewEmpty": "你还没有发布任何技能", "credentials": "访问凭证", - "openTokens": "打开 Token 页面", + "openTokens": "查看 API Tokens", "governanceTitle": "审核与治理", "viewGovernance": "打开治理中心", "viewPromotions": "查看提升审核", "reportsTitle": "举报管理", - "viewReports": "查看技能举报" + "viewReports": "查看技能举报", + "previewMore": "...", + "previewMoreLabel": "查看全部" }, "mySkills": { "title": "我的技能", diff --git a/web/src/pages/dashboard-preview.test.ts b/web/src/pages/dashboard-preview.test.ts new file mode 100644 index 00000000..0624595f --- /dev/null +++ b/web/src/pages/dashboard-preview.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { limitPreviewItems } from './dashboard-preview' + +describe('limitPreviewItems', () => { + it('returns all items when the list is within the limit', () => { + expect(limitPreviewItems(['a', 'b'], 3)).toEqual({ + items: ['a', 'b'], + hasMore: false, + remainingCount: 0, + }) + }) + + it('returns only the first items and reports the remaining count', () => { + expect(limitPreviewItems(['a', 'b', 'c', 'd'], 3)).toEqual({ + items: ['a', 'b', 'c'], + hasMore: true, + remainingCount: 1, + }) + }) + + it('supports a five-item preview before the ellipsis entry', () => { + expect(limitPreviewItems(['a', 'b', 'c', 'd', 'e', 'f'], 5)).toEqual({ + items: ['a', 'b', 'c', 'd', 'e'], + hasMore: true, + remainingCount: 1, + }) + }) +}) diff --git a/web/src/pages/dashboard-preview.ts b/web/src/pages/dashboard-preview.ts new file mode 100644 index 00000000..66fd18c9 --- /dev/null +++ b/web/src/pages/dashboard-preview.ts @@ -0,0 +1,14 @@ +export function limitPreviewItems(items: T[], limit: number): { + items: T[] + hasMore: boolean + remainingCount: number +} { + const visibleItems = items.slice(0, limit) + const remainingCount = Math.max(items.length - visibleItems.length, 0) + + return { + items: visibleItems, + hasMore: remainingCount > 0, + remainingCount, + } +} diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 12d96d56..ee696144 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,13 +1,19 @@ import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' +import { useMySkills } from '@/shared/hooks/use-skill-queries' import { TokenList } from '@/features/token/token-list' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { limitPreviewItems } from './dashboard-preview' + +const DASHBOARD_PREVIEW_LIMIT = 5 export function DashboardPage() { const { t } = useTranslation() const { user, hasRole } = useAuth() const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + const { data: skills, isLoading: isLoadingSkills } = useMySkills() + const skillPreview = limitPreviewItems(skills ?? [], DASHBOARD_PREVIEW_LIMIT) return (
@@ -59,13 +65,19 @@ export function DashboardPage() { -
+
{t('dashboard.starsAndRatings')}
{t('dashboard.viewStars')}
+ +
{t('dashboard.mySkillsTitle')}
+ + {t('dashboard.openMySkills')} + +
{t('dashboard.credentials')}
@@ -88,7 +100,62 @@ export function DashboardPage() { ) : null}
- +
+
+
+

{t('mySkills.title')}

+ + {t('dashboard.openMySkills')} + +
+

{t('dashboard.mySkillsPreviewDescription')}

+ + + {isLoadingSkills ? ( +
+ {Array.from({ length: DASHBOARD_PREVIEW_LIMIT + 1 }).map((_, index) => ( +
+ ))} +
+ ) : skillPreview.items.length > 0 ? ( +
+
+ {skillPreview.items.map((skill) => ( + +
{skill.displayName}
+
@{skill.namespace}
+ {skill.latestVersion ? ( +
+ v{skill.latestVersion} +
+ ) : null} + + ))} + + {t('dashboard.previewMore')} + {t('dashboard.previewMoreLabel')} + +
+
+ ) : ( +
{t('dashboard.mySkillsPreviewEmpty')}
+ )} + + +
+ +
+ +
+
) } diff --git a/web/src/pages/device.tsx b/web/src/pages/device.tsx index a67e189d..4de463e3 100644 --- a/web/src/pages/device.tsx +++ b/web/src/pages/device.tsx @@ -5,6 +5,7 @@ import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { fetchJson, getCsrfHeaders } from '@/api/client' +import { truncateErrorMessage } from '@/shared/lib/error-display' async function authorizeDevice(userCode: string): Promise { await fetchJson('/api/v1/device/authorize', { @@ -80,7 +81,7 @@ export function DeviceAuthPage() { } catch (error) { setMessage({ type: 'error', - text: error instanceof Error ? error.message : t('device.defaultError') + text: truncateErrorMessage(error instanceof Error ? error.message : t('device.defaultError')) ?? t('device.defaultError'), }) } finally { setIsSubmitting(false) diff --git a/web/src/pages/home.tsx b/web/src/pages/home.tsx index 73a66c60..3b1ef4c9 100644 --- a/web/src/pages/home.tsx +++ b/web/src/pages/home.tsx @@ -4,6 +4,7 @@ import { SearchBar } from '@/features/search/search-bar' import { SkillCard } from '@/features/skill/skill-card' import { SkeletonList } from '@/shared/components/skeleton-loader' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' +import { normalizeSearchQuery } from '@/shared/lib/search-query' import { Button } from '@/shared/ui/button' import { Check, Copy, Terminal, Settings, PackageOpen } from 'lucide-react' import { useState, useMemo } from 'react' @@ -159,7 +160,7 @@ export function HomePage() { }) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0, starredOnly: false } }) + navigate({ to: '/search', search: { q: normalizeSearchQuery(query), sort: 'relevance', page: 0, starredOnly: false } }) } const handleSkillClick = (namespace: string, slug: string) => { diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index fb86f305..adc5b89f 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { SearchBar } from '@/features/search/search-bar' import { useAuth } from '@/features/auth/use-auth' import { LanguageSwitcher } from '@/shared/components/language-switcher' +import { normalizeSearchQuery } from '@/shared/lib/search-query' import { UserMenu } from '@/shared/components/user-menu' import { Button } from '@/shared/ui/button' import { Check, Copy, Terminal, Settings, PackageOpen } from 'lucide-react' @@ -250,7 +251,7 @@ export function LandingPage() { }, []) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0, starredOnly: false } }) + navigate({ to: '/search', search: { q: normalizeSearchQuery(query), sort: 'relevance', page: 0, starredOnly: false } }) } const features = [ diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 5556dfeb..7c47ba89 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -10,6 +10,7 @@ import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' import { useMyStars, useSearchSkills } from '@/shared/hooks/use-skill-queries' +import { normalizeSearchQuery } from '@/shared/lib/search-query' import { Button } from '@/shared/ui/button' const PAGE_SIZE = 12 @@ -44,7 +45,7 @@ export function SearchPage() { const searchParams = useSearch({ from: '/search' }) const { isAuthenticated } = useAuth() - const q = searchParams.q || '' + const q = normalizeSearchQuery(searchParams.q || '') const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false @@ -68,7 +69,7 @@ export function SearchPage() { } = useMyStars(starredOnly && isAuthenticated) useEffect(() => { - const normalizedQuery = queryInput.trim() + const normalizedQuery = normalizeSearchQuery(queryInput) if (normalizedQuery === q) { return } @@ -90,7 +91,7 @@ export function SearchPage() { }, [navigate, page, q, queryInput, sort, starredOnly]) const handleSearch = (query: string) => { - const normalizedQuery = query.trim() + const normalizedQuery = normalizeSearchQuery(query) setQueryInput(query) startTransition(() => { navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0, starredOnly }, replace: true }) diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index 222149f9..b9df721e 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' @@ -25,7 +26,9 @@ export function AccountSettingsPage() { setVerificationToken(result.verificationToken) setStatusMessage(t('accounts.initiateSuccess', { secondaryUserId: result.secondaryUserId })) } catch (error) { - setStatusMessage(error instanceof Error ? error.message : t('accounts.initiateError')) + setStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.initiateError')) ?? t('accounts.initiateError'), + ) } } @@ -39,7 +42,9 @@ export function AccountSettingsPage() { }) setStatusMessage(t('accounts.verifySuccess')) } catch (error) { - setStatusMessage(error instanceof Error ? error.message : t('accounts.verifyError')) + setStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.verifyError')) ?? t('accounts.verifyError'), + ) } } @@ -49,7 +54,9 @@ export function AccountSettingsPage() { await confirmMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) }) setStatusMessage(t('accounts.confirmSuccess')) } catch (error) { - setStatusMessage(error instanceof Error ? error.message : t('accounts.confirmError')) + setStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.confirmError')) ?? t('accounts.confirmError'), + ) } } diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index 0426c9e6..85b84ab0 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { ApiError, authApi } from '@/api/client' +import { truncateErrorMessage } from '@/shared/lib/error-display' import { toast } from '@/shared/lib/toast' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -49,7 +50,9 @@ export function SecuritySettingsPage() { if (error instanceof ApiError && error.status === 401) { setErrorMessage(t('security.invalidCurrentPassword')) } else { - setErrorMessage(error instanceof Error ? error.message : t('security.defaultError')) + setErrorMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('security.defaultError')) ?? t('security.defaultError'), + ) } } finally { setIsSubmitting(false) diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 3e5e19c8..dd7da5b3 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -9,9 +9,10 @@ import { InstallCommand } from '@/features/skill/install-command' import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' -import { adminApi, ApiError, WEB_API_PREFIX } from '@/api/client' +import { adminApi, ApiError, skillDownloadApi } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { formatLocalDateTime } from '@/shared/lib/date-time' +import { incrementSkillDownloadCount } from '@/shared/lib/skill-download-cache' import { formatCompactCount } from '@/shared/lib/number-format' import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation' import { NamespaceBadge } from '@/shared/components/namespace-badge' @@ -135,7 +136,18 @@ export function SkillDetailPage() { const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) - const handleDownload = () => { + const triggerBrowserDownload = (blob: Blob, fileName: string) => { + const objectUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = objectUrl + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0) + } + + const handleDownload = async () => { if (!user) { requireLogin() return @@ -143,9 +155,21 @@ export function SkillDetailPage() { if (!selectedVersionEntry || isPendingPreview) { return } - const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${selectedVersionEntry.version}/download` - window.open(downloadUrl, '_blank') + + try { + const downloadedFile = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) + triggerBrowserDownload( + downloadedFile.blob, + downloadedFile.fileName ?? `${slug}-${selectedVersionEntry.version}.zip`, + ) + incrementSkillDownloadCount(queryClient, { namespace, slug }) + queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'search'] }) + } catch (error) { + toast.error(t('skillDetail.reportErrorTitle'), error instanceof Error ? error.message : '') + } } const requireLogin = () => { diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 2e6304dd..1a4d3a44 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,12 +1,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, promotionApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' +import { normalizeSearchQuery } from '@/shared/lib/search-query' const PUBLISH_REQUEST_TIMEOUT_MS = 60_000 async function searchSkills(params: SearchParams): Promise> { const queryParams = new URLSearchParams() - if (params.q) queryParams.append('q', params.q) + const normalizedQuery = normalizeSearchQuery(params.q ?? '') + if (normalizedQuery) queryParams.append('q', normalizedQuery) if (params.namespace) { const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace queryParams.append('namespace', cleanNamespace) diff --git a/web/src/shared/lib/error-display.test.ts b/web/src/shared/lib/error-display.test.ts new file mode 100644 index 00000000..f1b486f7 --- /dev/null +++ b/web/src/shared/lib/error-display.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { MAX_ERROR_MESSAGE_LENGTH, truncateErrorMessage } from './error-display' + +describe('truncateErrorMessage', () => { + it('keeps short messages unchanged', () => { + expect(truncateErrorMessage('publish failed')).toBe('publish failed') + }) + + it('truncates long messages and appends an ellipsis', () => { + const message = 'x'.repeat(MAX_ERROR_MESSAGE_LENGTH + 20) + + expect(truncateErrorMessage(message)).toBe(`${'x'.repeat(MAX_ERROR_MESSAGE_LENGTH)}...`) + }) +}) diff --git a/web/src/shared/lib/error-display.ts b/web/src/shared/lib/error-display.ts new file mode 100644 index 00000000..c9bd7899 --- /dev/null +++ b/web/src/shared/lib/error-display.ts @@ -0,0 +1,18 @@ +export const MAX_ERROR_MESSAGE_LENGTH = 180 + +export function truncateErrorMessage(message?: string): string | undefined { + if (!message) { + return undefined + } + + const normalizedMessage = message.trim() + if (!normalizedMessage) { + return undefined + } + + if (normalizedMessage.length <= MAX_ERROR_MESSAGE_LENGTH) { + return normalizedMessage + } + + return `${normalizedMessage.slice(0, MAX_ERROR_MESSAGE_LENGTH)}...` +} diff --git a/web/src/shared/lib/search-query.test.ts b/web/src/shared/lib/search-query.test.ts new file mode 100644 index 00000000..b0322df0 --- /dev/null +++ b/web/src/shared/lib/search-query.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery } from './search-query' + +describe('normalizeSearchQuery', () => { + it('trims whitespace around the query', () => { + expect(normalizeSearchQuery(' hello world ')).toBe('hello world') + }) + + it('limits the query to fifty characters', () => { + const query = 'a'.repeat(MAX_SEARCH_QUERY_LENGTH + 12) + + expect(normalizeSearchQuery(query)).toHaveLength(MAX_SEARCH_QUERY_LENGTH) + expect(normalizeSearchQuery(query)).toBe('a'.repeat(MAX_SEARCH_QUERY_LENGTH)) + }) +}) diff --git a/web/src/shared/lib/search-query.ts b/web/src/shared/lib/search-query.ts new file mode 100644 index 00000000..1b28b3ea --- /dev/null +++ b/web/src/shared/lib/search-query.ts @@ -0,0 +1,5 @@ +export const MAX_SEARCH_QUERY_LENGTH = 50 + +export function normalizeSearchQuery(query: string): string { + return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH) +} diff --git a/web/src/shared/lib/skill-download-cache.test.ts b/web/src/shared/lib/skill-download-cache.test.ts new file mode 100644 index 00000000..38ca71ce --- /dev/null +++ b/web/src/shared/lib/skill-download-cache.test.ts @@ -0,0 +1,85 @@ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it } from 'vitest' +import type { PagedResponse, SkillDetail, SkillSummary } from '@/api/types' +import { incrementSkillDownloadCount } from './skill-download-cache' + +function createSkillSummary(overrides: Partial = {}): SkillSummary { + return { + id: 1, + slug: 'demo-skill', + displayName: 'Demo Skill', + summary: 'summary', + status: 'PUBLISHED', + downloadCount: 10, + starCount: 2, + ratingAvg: 5, + ratingCount: 1, + latestVersion: '1.0.0', + latestVersionId: 100, + latestVersionStatus: 'PUBLISHED', + namespace: 'team', + updatedAt: '2026-03-16T00:00:00Z', + canSubmitPromotion: false, + ...overrides, + } +} + +function createSkillDetail(overrides: Partial = {}): SkillDetail { + return { + id: 1, + slug: 'demo-skill', + displayName: 'Demo Skill', + summary: 'summary', + visibility: 'PUBLIC', + status: 'ACTIVE', + downloadCount: 10, + starCount: 2, + ratingAvg: 5, + ratingCount: 1, + hidden: false, + latestVersion: '1.0.0', + latestVersionId: 100, + namespace: 'team', + canManageLifecycle: false, + canSubmitPromotion: false, + viewingVersionStatus: 'PUBLISHED', + canInteract: true, + ...overrides, + } +} + +describe('incrementSkillDownloadCount', () => { + it('increments the skill detail and cached list entries for the downloaded skill', () => { + const queryClient = new QueryClient() + const searchPage: PagedResponse = { + items: [ + createSkillSummary(), + createSkillSummary({ id: 2, slug: 'other-skill', displayName: 'Other Skill', downloadCount: 4 }), + ], + total: 2, + page: 0, + size: 12, + } + + queryClient.setQueryData(['skills', '@team', 'demo-skill'], createSkillDetail({ namespace: 'team' })) + queryClient.setQueryData(['skills', 'my'], searchPage.items) + queryClient.setQueryData(['skills', 'stars'], searchPage.items) + queryClient.setQueryData(['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }], searchPage) + + incrementSkillDownloadCount(queryClient, { namespace: '@team', slug: 'demo-skill' }) + + expect(queryClient.getQueryData(['skills', '@team', 'demo-skill'])?.downloadCount).toBe(11) + expect(queryClient.getQueryData(['skills', 'my'])?.[0]?.downloadCount).toBe(11) + expect(queryClient.getQueryData(['skills', 'stars'])?.[0]?.downloadCount).toBe(11) + expect( + queryClient.getQueryData>( + ['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }], + )?.items[0]?.downloadCount, + ).toBe(11) + expect( + queryClient.getQueryData>( + ['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }], + )?.items[1]?.downloadCount, + ).toBe(4) + }) +}) diff --git a/web/src/shared/lib/skill-download-cache.ts b/web/src/shared/lib/skill-download-cache.ts new file mode 100644 index 00000000..d5af24c5 --- /dev/null +++ b/web/src/shared/lib/skill-download-cache.ts @@ -0,0 +1,77 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { PagedResponse, SkillDetail, SkillSummary } from '@/api/types' + +type SkillIdentity = { + namespace: string + slug: string +} + +function normalizeNamespace(namespace: string): string { + return namespace.startsWith('@') ? namespace.slice(1) : namespace +} + +function matchesSkill(skill: SkillIdentity, target: SkillIdentity): boolean { + return normalizeNamespace(skill.namespace) === normalizeNamespace(target.namespace) && skill.slug === target.slug +} + +function incrementSummaryDownloadCount(skill: SkillSummary, target: SkillIdentity): SkillSummary { + if (!matchesSkill(skill, target)) { + return skill + } + return { + ...skill, + downloadCount: skill.downloadCount + 1, + } +} + +function incrementDetailDownloadCount(skill: SkillDetail | undefined, target: SkillIdentity): SkillDetail | undefined { + if (!skill || !matchesSkill(skill, target)) { + return skill + } + return { + ...skill, + downloadCount: skill.downloadCount + 1, + } +} + +function incrementSummaryList( + skills: SkillSummary[] | undefined, + target: SkillIdentity, +): SkillSummary[] | undefined { + return skills?.map((skill) => incrementSummaryDownloadCount(skill, target)) +} + +function incrementPagedSummaryList( + page: PagedResponse | undefined, + target: SkillIdentity, +): PagedResponse | undefined { + if (!page) { + return page + } + return { + ...page, + items: page.items.map((skill) => incrementSummaryDownloadCount(skill, target)), + } +} + +export function incrementSkillDownloadCount( + queryClient: QueryClient, + target: SkillIdentity, +): void { + queryClient.setQueryData( + ['skills', target.namespace, target.slug], + (current) => incrementDetailDownloadCount(current, target), + ) + queryClient.setQueryData( + ['skills', 'my'], + (current) => incrementSummaryList(current, target), + ) + queryClient.setQueryData( + ['skills', 'stars'], + (current) => incrementSummaryList(current, target), + ) + queryClient.setQueriesData>( + { queryKey: ['skills', 'search'] }, + (current) => incrementPagedSummaryList(current, target), + ) +} diff --git a/web/src/shared/lib/toast.ts b/web/src/shared/lib/toast.ts index 8a9254f3..b06bfb5c 100644 --- a/web/src/shared/lib/toast.ts +++ b/web/src/shared/lib/toast.ts @@ -1,4 +1,5 @@ import { toast as sonnerToast, type ExternalToast } from 'sonner' +import { truncateErrorMessage } from './error-display' export const CENTER_TOASTER_ID = 'top-center' @@ -26,7 +27,10 @@ export const toast = { sonnerToast.success(message, { description, ...withDefaultToaster(options) }) }, error: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.error(message, { description, ...withDefaultToaster(options) }) + sonnerToast.error(truncateErrorMessage(message) ?? message, { + description: truncateErrorMessage(description), + ...withDefaultToaster(options), + }) }, warning: (message: string, description?: string, options?: ExternalToast) => { sonnerToast.warning(message, { description, ...withDefaultToaster(options) }) From 3e67e12344262dca000714c59847cc1b3e499068 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 19:25:00 +0800 Subject: [PATCH 03/23] fix: clarify global namespace visibility copy --- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/dashboard/publish.tsx | 6 +++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a72d31ee..ea905c63 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -845,6 +845,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..ddb1078e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -845,6 +845,7 @@ "visibilityOptions": { "public": "公开", "namespaceOnly": "仅命名空间", + "loggedInUsersOnly": "仅登录用户可见", "private": "私有" }, "file": "技能包文件", 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)} > - +
From 723ddf559c55e9e60e29eda439d77286ac6e1b95 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 19:23:15 +0800 Subject: [PATCH 04/23] Configure default session timeout --- server/skillhub-app/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index b8dbc110..7facba1c 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: From 4a102c6507314b28b91ff69a831ea0c976f46dc0 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 20:12:52 +0800 Subject: [PATCH 05/23] Fix SKILL.md frontmatter rendering --- web/package.json | 3 + web/pnpm-lock.yaml | 58 ++++++++++++++++++++ web/src/features/skill/markdown-renderer.tsx | 20 ++++++- 3 files changed, 80 insertions(+), 1 deletion(-) 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/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 (
( From c23960ea0b0310c003046f5437857e41bd937b80 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 16 Mar 2026 14:16:52 +0800 Subject: [PATCH 06/23] fix(web): prevent text overflow in token dialogs Add responsive text wrapping and overflow handling to token-related components to prevent layout breaking with long token names or descriptions. Changes: - Add min-w-0 to dialog headers to allow flex shrinking - Add break-all/break-words to prevent text overflow - Add overflow-hidden to token display container - Apply max-w-48 constraint to token name column --- web/src/features/token/create-token-dialog.tsx | 10 +++++----- web/src/features/token/token-list.tsx | 6 +++--- web/src/shared/components/confirm-dialog.tsx | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 8d967f72..ca167fb6 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -210,22 +210,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..c2110064 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -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/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}}
{fieldErrors.password ? ( From dfa5837456d5ea5bc86d0b3612bdf9ed8e577cb5 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 16 Mar 2026 16:27:49 +0800 Subject: [PATCH 09/23] fix(auth): invalidate session when user account is disabled --- .../iflytek/skillhub/controller/AuthController.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 a7c0adfa..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 @@ -5,6 +5,9 @@ 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; @@ -45,6 +48,7 @@ public class AuthController extends BaseApiController { private final AuthFailureThrottleService authFailureThrottleService; private final UserRoleBindingRepository userRoleBindingRepository; private final PlatformSessionService platformSessionService; + private final UserAccountRepository userAccountRepository; public AuthController(ApiResponseFactory responseFactory, AuthMethodCatalog authMethodCatalog, @@ -52,7 +56,8 @@ public class AuthController extends BaseApiController { DirectAuthService directAuthService, AuthFailureThrottleService authFailureThrottleService, UserRoleBindingRepository userRoleBindingRepository, - PlatformSessionService platformSessionService) { + PlatformSessionService platformSessionService, + UserAccountRepository userAccountRepository) { super(responseFactory); this.authMethodCatalog = authMethodCatalog; this.sessionBootstrapService = sessionBootstrapService; @@ -60,6 +65,7 @@ public class AuthController extends BaseApiController { this.authFailureThrottleService = authFailureThrottleService; this.userRoleBindingRepository = userRoleBindingRepository; this.platformSessionService = platformSessionService; + this.userAccountRepository = userAccountRepository; } @GetMapping("/me") @@ -69,6 +75,11 @@ public class AuthController extends BaseApiController { 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()) From 060a611a54ea018eabf394440bbf9711fa5de966 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 16 Mar 2026 19:11:23 +0800 Subject: [PATCH 10/23] fix(auth): enforce owner isolation for skill publishing - Change skill uniqueness constraint from (namespace_id, slug) to (namespace_id, slug, owner_id) to support per-user skill records - Reject publish when another owner has a published skill with same slug - Reject review approval when same-slug conflict exists - Auto-withdraw pending review versions when submitting a new version - Resolve visible skill by preferring published skill, then current user's own skill, to fix wrong skill returned for same-slug queries - Invalidate all skill query cache on publish to prevent stale data --- .../compat/ClawHubCompatController.java | 31 +++- .../portal/SkillLifecycleController.java | 37 ++++- .../portal/SkillReportController.java | 31 +++- .../migration/V12__skill_owner_uniqueness.sql | 6 + .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../portal/SkillLifecycleControllerTest.java | 10 +- .../portal/SkillReportControllerTest.java | 2 +- .../skillhub/domain/review/ReviewService.java | 20 ++- .../domain/skill/SkillRepository.java | 3 +- .../skill/service/SkillDownloadService.java | 33 +++- .../skill/service/SkillPublishService.java | 27 +++- .../skill/service/SkillQueryService.java | 43 ++++-- .../domain/skill/service/SkillTagService.java | 32 +++- .../domain/review/ReviewServiceTest.java | 31 ++++ .../service/SkillDownloadServiceTest.java | 9 +- .../service/SkillPublishServiceTest.java | 144 +++++++++++++++++- .../skill/service/SkillQueryServiceTest.java | 38 ++--- .../skill/service/SkillTagServiceTest.java | 6 +- .../infra/jpa/JpaSkillRepositoryAdapter.java | 7 +- .../infra/jpa/SkillJpaRepository.java | 3 +- web/src/shared/hooks/use-skill-queries.ts | 2 +- 22 files changed, 427 insertions(+), 92 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql 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..806f910d 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 @@ -250,8 +250,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) { @@ -325,8 +324,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()); @@ -341,8 +339,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()); @@ -421,4 +418,26 @@ public class ClawHubCompatController { principal.avatarUrl() ); } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + java.util.Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + java.util.Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } } 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..42b357cc 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 @@ -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,32 @@ 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) { + java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + java.util.Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + java.util.Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); } } 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..a8daa230 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 @@ -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,32 @@ 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 resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); } -} + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + java.util.Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + java.util.Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } +} \ No newline at end of file diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql b/server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql new file mode 100644 index 00000000..f1fac58b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V12__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/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-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..f7e047b9 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,27 @@ public class ReviewService { SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + + 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/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 8d18b875..a53f1bbc 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 @@ -14,7 +14,9 @@ import org.springframework.stereotype.Service; import java.io.InputStream; import java.time.Duration; +import java.util.List; import java.util.Map; +import java.util.Optional; @Service public class SkillDownloadService { @@ -59,8 +61,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 +86,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 +107,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 +154,28 @@ public class SkillDownloadService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } + 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..406bf6f1 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 @@ -188,8 +188,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 +215,14 @@ 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) { + 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..4b192bf2 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 @@ -114,8 +114,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 +181,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 +206,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 +222,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 +236,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 +255,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 +268,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 +312,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 +339,30 @@ 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) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + + if (currentUserId != null) { + Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + + return skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); } private SkillVersion findVersion(Skill skill, String version) { 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..b99ac0e2 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 @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Service public class SkillTagService { @@ -44,8 +45,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 +76,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 +110,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 +123,28 @@ public class SkillTagService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } + 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..b71cac4a 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", @@ -356,6 +359,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 +383,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/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index b077f24a..696c12fb 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; @@ -82,7 +83,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 +125,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 +165,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 +197,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..27e87acc 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,129 @@ 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); + + 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(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(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..79f9e611 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 @@ -87,7 +87,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)); @@ -115,7 +115,7 @@ class SkillQueryServiceTest { setId(skill, 1L); 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 @@ -189,7 +189,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 +219,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 +246,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 +279,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 +313,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 +351,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 +385,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 +427,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 +460,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); @@ -574,7 +574,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); @@ -607,7 +607,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 +642,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 +674,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 +709,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)); @@ -739,7 +739,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, viewerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); @@ -771,7 +771,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)); @@ -805,7 +805,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.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); 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..32c1ee71 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 @@ -73,7 +73,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 +118,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 +175,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/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 1a4d3a44..ab4da559 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -246,7 +246,7 @@ export function usePublishSkill() { skipGlobalErrorHandler: true, }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) }, }) } From 316a86628ced73a81ef5cb4b3e6e1e00d147bc1e Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 20:36:24 +0800 Subject: [PATCH 11/23] fix(skill): isolate unpublished slug access --- .../compat/ClawHubCompatController.java | 30 ++--- .../portal/SkillLifecycleController.java | 32 ++---- .../portal/SkillReportController.java | 38 ++----- .../skillhub/domain/review/ReviewService.java | 3 + .../domain/skill/VisibilityChecker.java | 3 + .../skill/service/SkillDownloadService.java | 31 ++---- .../skill/service/SkillPublishService.java | 3 + .../skill/service/SkillQueryService.java | 28 ++--- .../service/SkillSlugResolutionService.java | 46 ++++++++ .../domain/skill/service/SkillTagService.java | 30 ++--- .../domain/review/ReviewServiceTest.java | 18 +++ .../domain/skill/VisibilityCheckerTest.java | 30 +++++ .../service/SkillDownloadServiceTest.java | 5 +- .../service/SkillPublishServiceTest.java | 4 + .../skill/service/SkillQueryServiceTest.java | 103 +++++++++++++++++- .../SkillSlugResolutionServiceTest.java | 69 ++++++++++++ .../skill/service/SkillTagServiceTest.java | 5 +- 17 files changed, 340 insertions(+), 138 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index 806f910d..8c068d18 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 @@ -23,6 +23,7 @@ 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.service.SkillSearchAppService; @@ -55,6 +56,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 +68,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,6 +81,7 @@ public class ClawHubCompatController { this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarService = skillStarService; + this.skillSlugResolutionService = skillSlugResolutionService; } @GetMapping("/search") @@ -420,24 +424,14 @@ public class ClawHubCompatController { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { + 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); } - java.util.Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - java.util.Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); } } 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 42b357cc..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") @@ -189,24 +189,10 @@ public class SkillLifecycleController extends BaseApiController { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - java.util.Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - java.util.Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); + 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 a8daa230..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") @@ -59,28 +59,10 @@ public class SkillReportController extends BaseApiController { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); - return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + return skillSlugResolutionService.resolve( + namespace.getId(), + skillSlug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED); } - - private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - java.util.Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - java.util.Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); - } -} \ No newline at end of file +} 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 f7e047b9..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 @@ -154,6 +154,9 @@ 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())); 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 a53f1bbc..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 @@ -14,9 +14,7 @@ import org.springframework.stereotype.Service; import java.io.InputStream; import java.time.Duration; -import java.util.List; import java.util.Map; -import java.util.Optional; @Service public class SkillDownloadService { @@ -28,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, @@ -36,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; @@ -44,6 +44,7 @@ public class SkillDownloadService { this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; this.eventPublisher = eventPublisher; + this.skillSlugResolutionService = skillSlugResolutionService; } public record DownloadResult( @@ -155,25 +156,11 @@ public class SkillDownloadService { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } private void assertPublishedAccessible(Skill skill) { 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 406bf6f1..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; @@ -219,6 +220,8 @@ public class SkillPublishService { 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); } 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 4b192bf2..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( @@ -345,24 +348,11 @@ public class SkillQueryService { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - - if (currentUserId != null) { - Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - - return skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst() - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); + 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 b99ac0e2..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 @@ -11,7 +11,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; @Service public class SkillTagService { @@ -24,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, @@ -31,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, @@ -124,25 +126,11 @@ public class SkillTagService { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } private void assertAdminOrOwner(Long namespaceId, String operatorId) { 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 b71cac4a..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 @@ -319,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(); 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 696c12fb..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 @@ -46,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, @@ -56,7 +58,8 @@ class SkillDownloadServiceTest { skillTagRepository, objectStorageService, visibilityChecker, - eventPublisher + eventPublisher, + skillSlugResolutionService ); } 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 27e87acc..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 @@ -643,6 +643,7 @@ class SkillPublishServiceTest { 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)); @@ -652,6 +653,8 @@ class SkillPublishServiceTest { 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); @@ -664,6 +667,7 @@ class SkillPublishServiceTest { // Verify pending version was withdrawn to DRAFT assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + verify(reviewTaskRepository).delete(pendingTask); verify(skillVersionRepository).save(pendingV1); } 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 79f9e611..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 ); } @@ -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,6 +151,7 @@ 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(List.of(skill)); @@ -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 @@ -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(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); @@ -737,6 +828,7 @@ 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(List.of(skill)); @@ -803,6 +895,7 @@ 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(List.of(skill)); 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 32c1ee71..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 ); } From 330f64ceddf33daab6282dc414cc1cc8cb1d1e00 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 21:03:37 +0800 Subject: [PATCH 12/23] fix(web): avoid CSP errors when downloading skills --- web/src/api/client.ts | 45 ++++++++++++---------------------- web/src/pages/skill-detail.tsx | 14 +++-------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bb0da6d9..7979d600 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,9 +37,8 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' -export type DownloadedFile = { - blob: Blob - fileName?: string +export type SkillDownloadRequest = { + url: string } type RuntimeConfig = { @@ -278,20 +277,6 @@ function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } -function parseDownloadFileName(contentDisposition: string | null): string | undefined { - if (!contentDisposition) { - return undefined - } - - const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) - if (utf8Match) { - return decodeURIComponent(utf8Match[1]) - } - - const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) - return basicMatch?.[1] -} - export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me', { @@ -442,23 +427,25 @@ export const accountApi = { } export const skillDownloadApi = { - async downloadVersion(namespace: string, slug: string, version: string): Promise { + async downloadVersion(namespace: string, slug: string, version: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const response = await fetch( - withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), - { - headers: withRequestHeaders(), - }, - ) + const requestUrl = withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`) + const url = typeof requestUrl === 'string' ? requestUrl : requestUrl.toString() - if (!response.ok) { + const response = await fetch(requestUrl, { + headers: withRequestHeaders(), + redirect: 'manual', + }) + + if (response.type === 'opaqueredirect') { + return { url } + } + + if (!response.ok && response.status !== 0 && response.status !== 302) { throw new ApiError(`HTTP ${response.status}`, response.status) } - return { - blob: await response.blob(), - fileName: parseDownloadFileName(response.headers.get('content-disposition')), - } + return { url } }, } diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 184c5252..46c6013b 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -137,15 +137,12 @@ export function SkillDetailPage() { const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) - const triggerBrowserDownload = (blob: Blob, fileName: string) => { - const objectUrl = window.URL.createObjectURL(blob) + const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') - link.href = objectUrl - link.download = fileName + link.href = url document.body.appendChild(link) link.click() link.remove() - window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0) } const handleDownload = async () => { @@ -158,11 +155,8 @@ export function SkillDetailPage() { } try { - const downloadedFile = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) - triggerBrowserDownload( - downloadedFile.blob, - downloadedFile.fileName ?? `${slug}-${selectedVersionEntry.version}.zip`, - ) + const downloadRequest = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) + triggerBrowserDownload(downloadRequest.url) incrementSkillDownloadCount(queryClient, { namespace, slug }) queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) From 654f640abb9ef0c64ad489e126cbbfe909682edd Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 21:14:25 +0800 Subject: [PATCH 13/23] fix: migration sql version fixed --- ...skill_owner_uniqueness.sql => V13__skill_owner_uniqueness.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/skillhub-app/src/main/resources/db/migration/{V12__skill_owner_uniqueness.sql => V13__skill_owner_uniqueness.sql} (100%) diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql b/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql rename to server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql From 785c15cd0f51065c66c7f0da61854aa6f4db8e3f Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 20:12:52 +0800 Subject: [PATCH 14/23] Fix SKILL.md frontmatter rendering --- web/package.json | 3 + web/pnpm-lock.yaml | 58 ++++++++++++++++++++ web/src/features/skill/markdown-renderer.tsx | 20 ++++++- 3 files changed, 80 insertions(+), 1 deletion(-) 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/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 (
( From bd8ef13cbb81c09b078b393add41290a0c19011a Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 16 Mar 2026 14:16:52 +0800 Subject: [PATCH 15/23] fix(web): prevent text overflow in token dialogs Add responsive text wrapping and overflow handling to token-related components to prevent layout breaking with long token names or descriptions. Changes: - Add min-w-0 to dialog headers to allow flex shrinking - Add break-all/break-words to prevent text overflow - Add overflow-hidden to token display container - Apply max-w-48 constraint to token name column --- web/src/features/token/create-token-dialog.tsx | 10 +++++----- web/src/features/token/token-list.tsx | 6 +++--- web/src/shared/components/confirm-dialog.tsx | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 8d967f72..ca167fb6 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -210,22 +210,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..c2110064 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -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/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}}
{fieldErrors.password ? ( From 94a1e95bc7645ccd7c62d556b8cf34f5d029c0ba Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 16 Mar 2026 16:27:49 +0800 Subject: [PATCH 18/23] fix(auth): invalidate session when user account is disabled --- .../iflytek/skillhub/controller/AuthController.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 a7c0adfa..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 @@ -5,6 +5,9 @@ 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; @@ -45,6 +48,7 @@ public class AuthController extends BaseApiController { private final AuthFailureThrottleService authFailureThrottleService; private final UserRoleBindingRepository userRoleBindingRepository; private final PlatformSessionService platformSessionService; + private final UserAccountRepository userAccountRepository; public AuthController(ApiResponseFactory responseFactory, AuthMethodCatalog authMethodCatalog, @@ -52,7 +56,8 @@ public class AuthController extends BaseApiController { DirectAuthService directAuthService, AuthFailureThrottleService authFailureThrottleService, UserRoleBindingRepository userRoleBindingRepository, - PlatformSessionService platformSessionService) { + PlatformSessionService platformSessionService, + UserAccountRepository userAccountRepository) { super(responseFactory); this.authMethodCatalog = authMethodCatalog; this.sessionBootstrapService = sessionBootstrapService; @@ -60,6 +65,7 @@ public class AuthController extends BaseApiController { this.authFailureThrottleService = authFailureThrottleService; this.userRoleBindingRepository = userRoleBindingRepository; this.platformSessionService = platformSessionService; + this.userAccountRepository = userAccountRepository; } @GetMapping("/me") @@ -69,6 +75,11 @@ public class AuthController extends BaseApiController { 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()) From cb717fd5f44e4e7cc1116750b2d618509ff899a8 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 16 Mar 2026 19:11:23 +0800 Subject: [PATCH 19/23] fix(auth): enforce owner isolation for skill publishing - Change skill uniqueness constraint from (namespace_id, slug) to (namespace_id, slug, owner_id) to support per-user skill records - Reject publish when another owner has a published skill with same slug - Reject review approval when same-slug conflict exists - Auto-withdraw pending review versions when submitting a new version - Resolve visible skill by preferring published skill, then current user's own skill, to fix wrong skill returned for same-slug queries - Invalidate all skill query cache on publish to prevent stale data --- .../compat/ClawHubCompatController.java | 31 +++- .../portal/SkillLifecycleController.java | 37 ++++- .../portal/SkillReportController.java | 31 +++- .../migration/V12__skill_owner_uniqueness.sql | 6 + .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../portal/SkillLifecycleControllerTest.java | 10 +- .../portal/SkillReportControllerTest.java | 2 +- .../skillhub/domain/review/ReviewService.java | 20 ++- .../domain/skill/SkillRepository.java | 3 +- .../skill/service/SkillDownloadService.java | 33 +++- .../skill/service/SkillPublishService.java | 27 +++- .../skill/service/SkillQueryService.java | 43 ++++-- .../domain/skill/service/SkillTagService.java | 32 +++- .../domain/review/ReviewServiceTest.java | 31 ++++ .../service/SkillDownloadServiceTest.java | 9 +- .../service/SkillPublishServiceTest.java | 144 +++++++++++++++++- .../skill/service/SkillQueryServiceTest.java | 38 ++--- .../skill/service/SkillTagServiceTest.java | 6 +- .../infra/jpa/JpaSkillRepositoryAdapter.java | 7 +- .../infra/jpa/SkillJpaRepository.java | 3 +- web/src/shared/hooks/use-skill-queries.ts | 2 +- 22 files changed, 427 insertions(+), 92 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql 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 ef8a4f31..5ab0e4bb 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 @@ -304,8 +304,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) { @@ -382,8 +381,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()); @@ -399,8 +397,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()); @@ -482,4 +479,26 @@ public class ClawHubCompatController { principal.avatarUrl() ); } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + java.util.Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + java.util.Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } } 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..42b357cc 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 @@ -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,32 @@ 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) { + java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + java.util.Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + java.util.Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); } } 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..a8daa230 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 @@ -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,32 @@ 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 resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); } -} + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + java.util.Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + java.util.Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } +} \ No newline at end of file diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql b/server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql new file mode 100644 index 00000000..f1fac58b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V12__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/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-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..f7e047b9 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,27 @@ public class ReviewService { SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + + 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/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 8d18b875..a53f1bbc 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 @@ -14,7 +14,9 @@ import org.springframework.stereotype.Service; import java.io.InputStream; import java.time.Duration; +import java.util.List; import java.util.Map; +import java.util.Optional; @Service public class SkillDownloadService { @@ -59,8 +61,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 +86,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 +107,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 +154,28 @@ public class SkillDownloadService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } + 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..406bf6f1 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 @@ -188,8 +188,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 +215,14 @@ 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) { + 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..4b192bf2 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 @@ -114,8 +114,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 +181,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 +206,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 +222,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 +236,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 +255,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 +268,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 +312,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 +339,30 @@ 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) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + + if (currentUserId != null) { + Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + + return skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); } private SkillVersion findVersion(Skill skill, String version) { 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..b99ac0e2 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 @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Service public class SkillTagService { @@ -44,8 +45,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 +76,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 +110,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 +123,28 @@ public class SkillTagService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); + if (skills.isEmpty()) { + throw new DomainBadRequestException("error.skill.notFound", slug); + } + Optional published = skills.stream() + .filter(s -> s.getLatestVersionId() != null) + .findFirst(); + if (published.isPresent()) { + return published.get(); + } + if (currentUserId != null) { + Optional ownSkill = skills.stream() + .filter(s -> currentUserId.equals(s.getOwnerId())) + .findFirst(); + if (ownSkill.isPresent()) { + return ownSkill.get(); + } + } + return skills.get(0); + } + 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..b71cac4a 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", @@ -356,6 +359,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 +383,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/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index b077f24a..696c12fb 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; @@ -82,7 +83,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 +125,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 +165,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 +197,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..27e87acc 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,129 @@ 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); + + 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(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(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..79f9e611 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 @@ -87,7 +87,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)); @@ -115,7 +115,7 @@ class SkillQueryServiceTest { setId(skill, 1L); 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 @@ -189,7 +189,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 +219,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 +246,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 +279,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 +313,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 +351,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 +385,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 +427,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 +460,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); @@ -574,7 +574,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); @@ -607,7 +607,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 +642,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 +674,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 +709,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)); @@ -739,7 +739,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, viewerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); @@ -771,7 +771,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)); @@ -805,7 +805,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.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); 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..32c1ee71 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 @@ -73,7 +73,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 +118,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 +175,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/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 1a4d3a44..ab4da559 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -246,7 +246,7 @@ export function usePublishSkill() { skipGlobalErrorHandler: true, }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) }, }) } From ace60c477fed8b4ca9e0846d0c1672d1e228289d Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 20:36:24 +0800 Subject: [PATCH 20/23] fix(skill): isolate unpublished slug access --- .../compat/ClawHubCompatController.java | 30 ++--- .../portal/SkillLifecycleController.java | 32 ++---- .../portal/SkillReportController.java | 38 ++----- .../skillhub/domain/review/ReviewService.java | 3 + .../domain/skill/VisibilityChecker.java | 3 + .../skill/service/SkillDownloadService.java | 31 ++---- .../skill/service/SkillPublishService.java | 3 + .../skill/service/SkillQueryService.java | 28 ++--- .../service/SkillSlugResolutionService.java | 46 ++++++++ .../domain/skill/service/SkillTagService.java | 30 ++--- .../domain/review/ReviewServiceTest.java | 18 +++ .../domain/skill/VisibilityCheckerTest.java | 30 +++++ .../service/SkillDownloadServiceTest.java | 5 +- .../service/SkillPublishServiceTest.java | 4 + .../skill/service/SkillQueryServiceTest.java | 103 +++++++++++++++++- .../SkillSlugResolutionServiceTest.java | 69 ++++++++++++ .../skill/service/SkillTagServiceTest.java | 5 +- 17 files changed, 340 insertions(+), 138 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillSlugResolutionServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index 5ab0e4bb..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 @@ -24,6 +24,7 @@ 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; @@ -57,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, @@ -68,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; @@ -80,6 +83,7 @@ public class ClawHubCompatController { this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarService = skillStarService; + this.skillSlugResolutionService = skillSlugResolutionService; } @RateLimit(category = "search", authenticated = 60, anonymous = 20) @@ -481,24 +485,14 @@ public class ClawHubCompatController { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { + 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); } - java.util.Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - java.util.Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); } } 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 42b357cc..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") @@ -189,24 +189,10 @@ public class SkillLifecycleController extends BaseApiController { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - java.util.Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - java.util.Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); + 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 a8daa230..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") @@ -59,28 +59,10 @@ public class SkillReportController extends BaseApiController { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); - return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + return skillSlugResolutionService.resolve( + namespace.getId(), + skillSlug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED); } - - private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - java.util.List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - java.util.Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - java.util.Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); - } -} \ No newline at end of file +} 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 f7e047b9..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 @@ -154,6 +154,9 @@ 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())); 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 a53f1bbc..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 @@ -14,9 +14,7 @@ import org.springframework.stereotype.Service; import java.io.InputStream; import java.time.Duration; -import java.util.List; import java.util.Map; -import java.util.Optional; @Service public class SkillDownloadService { @@ -28,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, @@ -36,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; @@ -44,6 +44,7 @@ public class SkillDownloadService { this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; this.eventPublisher = eventPublisher; + this.skillSlugResolutionService = skillSlugResolutionService; } public record DownloadResult( @@ -155,25 +156,11 @@ public class SkillDownloadService { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } private void assertPublishedAccessible(Skill skill) { 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 406bf6f1..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; @@ -219,6 +220,8 @@ public class SkillPublishService { 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); } 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 4b192bf2..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( @@ -345,24 +348,11 @@ public class SkillQueryService { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - - if (currentUserId != null) { - Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - - return skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst() - .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug)); + 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 b99ac0e2..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 @@ -11,7 +11,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; @Service public class SkillTagService { @@ -24,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, @@ -31,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, @@ -124,25 +126,11 @@ public class SkillTagService { } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - List skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug); - if (skills.isEmpty()) { - throw new DomainBadRequestException("error.skill.notFound", slug); - } - Optional published = skills.stream() - .filter(s -> s.getLatestVersionId() != null) - .findFirst(); - if (published.isPresent()) { - return published.get(); - } - if (currentUserId != null) { - Optional ownSkill = skills.stream() - .filter(s -> currentUserId.equals(s.getOwnerId())) - .findFirst(); - if (ownSkill.isPresent()) { - return ownSkill.get(); - } - } - return skills.get(0); + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.CURRENT_USER); } private void assertAdminOrOwner(Long namespaceId, String operatorId) { 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 b71cac4a..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 @@ -319,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(); 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 696c12fb..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 @@ -46,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, @@ -56,7 +58,8 @@ class SkillDownloadServiceTest { skillTagRepository, objectStorageService, visibilityChecker, - eventPublisher + eventPublisher, + skillSlugResolutionService ); } 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 27e87acc..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 @@ -643,6 +643,7 @@ class SkillPublishServiceTest { 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)); @@ -652,6 +653,8 @@ class SkillPublishServiceTest { 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); @@ -664,6 +667,7 @@ class SkillPublishServiceTest { // Verify pending version was withdrawn to DRAFT assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + verify(reviewTaskRepository).delete(pendingTask); verify(skillVersionRepository).save(pendingV1); } 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 79f9e611..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 ); } @@ -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,6 +151,7 @@ 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(List.of(skill)); @@ -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 @@ -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(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); @@ -737,6 +828,7 @@ 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(List.of(skill)); @@ -803,6 +895,7 @@ 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(List.of(skill)); 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 32c1ee71..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 ); } From c7509411338da7f63721d4884446d3777f39140c Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 21:03:37 +0800 Subject: [PATCH 21/23] fix(web): avoid CSP errors when downloading skills --- web/src/api/client.ts | 45 ++++++++++++---------------------- web/src/pages/skill-detail.tsx | 14 +++-------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bb0da6d9..7979d600 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,9 +37,8 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' -export type DownloadedFile = { - blob: Blob - fileName?: string +export type SkillDownloadRequest = { + url: string } type RuntimeConfig = { @@ -278,20 +277,6 @@ function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } -function parseDownloadFileName(contentDisposition: string | null): string | undefined { - if (!contentDisposition) { - return undefined - } - - const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) - if (utf8Match) { - return decodeURIComponent(utf8Match[1]) - } - - const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) - return basicMatch?.[1] -} - export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me', { @@ -442,23 +427,25 @@ export const accountApi = { } export const skillDownloadApi = { - async downloadVersion(namespace: string, slug: string, version: string): Promise { + async downloadVersion(namespace: string, slug: string, version: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const response = await fetch( - withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), - { - headers: withRequestHeaders(), - }, - ) + const requestUrl = withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`) + const url = typeof requestUrl === 'string' ? requestUrl : requestUrl.toString() - if (!response.ok) { + const response = await fetch(requestUrl, { + headers: withRequestHeaders(), + redirect: 'manual', + }) + + if (response.type === 'opaqueredirect') { + return { url } + } + + if (!response.ok && response.status !== 0 && response.status !== 302) { throw new ApiError(`HTTP ${response.status}`, response.status) } - return { - blob: await response.blob(), - fileName: parseDownloadFileName(response.headers.get('content-disposition')), - } + return { url } }, } diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 184c5252..46c6013b 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -137,15 +137,12 @@ export function SkillDetailPage() { const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) - const triggerBrowserDownload = (blob: Blob, fileName: string) => { - const objectUrl = window.URL.createObjectURL(blob) + const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') - link.href = objectUrl - link.download = fileName + link.href = url document.body.appendChild(link) link.click() link.remove() - window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0) } const handleDownload = async () => { @@ -158,11 +155,8 @@ export function SkillDetailPage() { } try { - const downloadedFile = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) - triggerBrowserDownload( - downloadedFile.blob, - downloadedFile.fileName ?? `${slug}-${selectedVersionEntry.version}.zip`, - ) + const downloadRequest = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) + triggerBrowserDownload(downloadRequest.url) incrementSkillDownloadCount(queryClient, { namespace, slug }) queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) From ce16a27c8df35c363540ebce1680afd48f5f4595 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 21:14:25 +0800 Subject: [PATCH 22/23] fix: migration sql version fixed --- ...skill_owner_uniqueness.sql => V13__skill_owner_uniqueness.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/skillhub-app/src/main/resources/db/migration/{V12__skill_owner_uniqueness.sql => V13__skill_owner_uniqueness.sql} (100%) diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql b/server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V12__skill_owner_uniqueness.sql rename to server/skillhub-app/src/main/resources/db/migration/V13__skill_owner_uniqueness.sql From 66cbdd7b57f311c4c97c81c169e54634e2be9ed4 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 21:25:55 +0800 Subject: [PATCH 23/23] fix: official site urls removed --- .env.release.draft | 2 +- .../controller/DeviceAuthControllerTest.java | 89 ---------- .../auth/device/DeviceAuthServiceTest.java | 159 ------------------ 3 files changed, 1 insertion(+), 249 deletions(-) delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java delete mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java diff --git a/.env.release.draft b/.env.release.draft index c8c01d27..8a0c28e2 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -13,7 +13,7 @@ SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web # Public HTTPS entrypoint, no trailing slash. -SKILLHUB_PUBLIC_BASE_URL=https://skill.xfyun.cn +SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com # Usually keep empty when web and api are served from the same domain. SKILLHUB_WEB_API_BASE_URL= 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 068e81b9..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://skill.xfyun.cn/cli/auth", - 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://skill.xfyun.cn/cli/auth")) - .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-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 9be6a82d..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://skill.xfyun.cn/cli/auth"); - } - - @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://skill.xfyun.cn/cli/auth"); - 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.rotateToken("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()).rotateToken(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"); - } -}