From b520689130318bf7a97a69240ef498ce65da2aa1 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 17:47:58 +0800 Subject: [PATCH] feat(app): update controllers, DTOs, config, rate limiting, and add new components - Add BaseApiController, MeController, AuthContextFilter - Add standardized ApiResponse/ApiResponseFactory/PageResponse DTOs - Add i18n messages (en/zh) - Replace SlidingWindowRateLimiter with RateLimiter interface + Redis/InMemory impls - Add localized exception handling - Update application.yml with messages config - Update Flyway migrations V1-V3 - Add SkillController and SkillSearchController tests --- .../skillhub/controller/AuthController.java | 39 ++-- .../controller/BaseApiController.java | 17 ++ .../skillhub/controller/CliController.java | 28 ++- .../skillhub/controller/HealthController.java | 15 +- .../skillhub/controller/TokenController.java | 64 +++--- .../controller/cli/CliPublishController.java | 32 +-- .../controller/portal/MeController.java | 37 ++++ .../portal/NamespaceController.java | 79 +++---- .../portal/PromotionController.java | 8 +- .../controller/portal/ReviewController.java | 12 +- .../controller/portal/SkillController.java | 196 ++++++++++++++++-- .../portal/SkillPublishController.java | 32 +-- .../portal/SkillSearchController.java | 18 +- .../controller/portal/SkillTagController.java | 27 ++- .../com/iflytek/skillhub/dto/ApiResponse.java | 11 + .../skillhub/dto/ApiResponseFactory.java | 32 +++ .../iflytek/skillhub/dto/AuthMeResponse.java | 25 +++ .../skillhub/dto/AuthProviderResponse.java | 7 + .../skillhub/dto/CliWhoamiResponse.java | 25 +++ .../iflytek/skillhub/dto/ErrorResponse.java | 19 -- .../iflytek/skillhub/dto/MemberRequest.java | 6 +- .../iflytek/skillhub/dto/MemberResponse.java | 2 +- .../iflytek/skillhub/dto/MessageResponse.java | 5 + .../skillhub/dto/NamespaceRequest.java | 10 +- .../skillhub/dto/NamespaceResponse.java | 2 +- .../iflytek/skillhub/dto/PageResponse.java | 21 ++ .../skillhub/dto/PromotionResponseDto.java | 4 +- .../skillhub/dto/ResolveVersionResponse.java | 12 ++ .../skillhub/dto/ReviewTaskResponse.java | 4 +- .../skillhub/dto/SkillSummaryResponse.java | 9 +- .../dto/SkillVersionDetailResponse.java | 15 ++ .../skillhub/dto/TokenCreateRequest.java | 11 + .../skillhub/dto/TokenCreateResponse.java | 10 + .../skillhub/dto/TokenSummaryResponse.java | 10 + .../skillhub/dto/UpdateMemberRoleRequest.java | 9 + .../exception/GlobalExceptionHandler.java | 65 +++++- .../skillhub/exception/LocalizedError.java | 11 + .../exception/LocalizedException.java | 28 +++ .../exception/UnauthorizedException.java | 15 ++ .../skillhub/filter/AuthContextFilter.java | 64 ++++++ .../listener/SkillRatingEventListener.java | 29 +++ .../listener/SkillStarEventListener.java | 37 ++++ .../InMemorySlidingWindowRateLimiter.java | 41 ++++ .../ratelimit/RateLimitInterceptor.java | 21 +- .../skillhub/ratelimit/RateLimiter.java | 6 + ...ava => RedisSlidingWindowRateLimiter.java} | 9 +- .../security/ApiAccessDeniedHandler.java | 35 ++++ .../security/ApiAuthenticationEntryPoint.java | 35 ++++ .../skillhub/service/MySkillAppService.java | 89 ++++++++ .../service/SkillSearchAppService.java | 96 +++++++-- .../src/main/resources/application.yml | 2 + .../db/migration/V1__init_schema.sql | 18 +- .../db/migration/V2__phase2_skill_tables.sql | 12 +- .../V3__phase3_review_social_tables.sql | 12 +- .../src/main/resources/messages.properties | 69 ++++++ .../src/main/resources/messages_zh.properties | 69 ++++++ .../controller/AuthControllerTest.java | 28 ++- .../controller/CliControllerTest.java | 18 +- .../controller/HealthControllerTest.java | 6 +- .../controller/SkillControllerTest.java | 122 +++++++++++ .../controller/SkillSearchControllerTest.java | 57 +++++ .../SkillRatingEventListenerTest.java | 28 +++ .../listener/SkillStarEventListenerTest.java | 33 +++ .../src/test/resources/application-test.yml | 1 - 64 files changed, 1620 insertions(+), 289 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthProviderResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CliWhoamiResponse.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MessageResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PageResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResolveVersionResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateMemberRoleRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java rename server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/{SlidingWindowRateLimiter.java => RedisSlidingWindowRateLimiter.java} (85%) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java create mode 100644 server/skillhub-app/src/main/resources/messages.properties create mode 100644 server/skillhub-app/src/main/resources/messages_zh.properties create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillRatingEventListenerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillStarEventListenerTest.java 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 dabb0bf2..ec8dfca9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -1,43 +1,38 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import org.springframework.http.ResponseEntity; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AuthMeResponse; +import com.iflytek.skillhub.dto.AuthProviderResponse; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; -import java.util.Map; - @RestController @RequestMapping("/api/v1/auth") -public class AuthController { +public class AuthController extends BaseApiController { + + public AuthController(ApiResponseFactory responseFactory) { + super(responseFactory); + } @GetMapping("/me") - public ResponseEntity> me(@AuthenticationPrincipal PlatformPrincipal principal, - Authentication authentication) { + public ApiResponse me(@AuthenticationPrincipal PlatformPrincipal principal, + Authentication authentication) { if (principal == null || authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401).build(); + throw new UnauthorizedException("error.auth.required"); } - return ResponseEntity.ok(Map.of( - "userId", principal.userId(), - "displayName", principal.displayName(), - "email", principal.email() != null ? principal.email() : "", - "avatarUrl", principal.avatarUrl() != null ? principal.avatarUrl() : "", - "oauthProvider", principal.oauthProvider(), - "platformRoles", principal.platformRoles() - )); + return ok("response.success.read", AuthMeResponse.from(principal)); } @GetMapping("/providers") - public ResponseEntity> providers() { - var github = Map.of( - "id", "github", - "name", "GitHub", - "authorizationUrl", "/oauth2/authorization/github" - ); - return ResponseEntity.ok(Map.of("data", List.of(github))); + public ApiResponse> providers() { + var github = new AuthProviderResponse("github", "GitHub", "/oauth2/authorization/github"); + return ok("response.success.read", List.of(github)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java new file mode 100644 index 00000000..51a49de7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/BaseApiController.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; + +public abstract class BaseApiController { + + private final ApiResponseFactory responseFactory; + + protected BaseApiController(ApiResponseFactory responseFactory) { + this.responseFactory = responseFactory; + } + + protected ApiResponse ok(String messageCode, T data, Object... args) { + return responseFactory.ok(messageCode, data, args); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index af13b7b9..985c3790 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java @@ -1,33 +1,29 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import org.springframework.http.ResponseEntity; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.CliWhoamiResponse; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - @RestController @RequestMapping("/api/v1/cli") -public class CliController { +public class CliController extends BaseApiController { + + public CliController(ApiResponseFactory responseFactory) { + super(responseFactory); + } @GetMapping("/whoami") - public ResponseEntity> whoami(@AuthenticationPrincipal PlatformPrincipal principal) { + public ApiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { if (principal == null) { - return ResponseEntity.status(401).build(); + throw new UnauthorizedException("error.auth.required"); } - return ResponseEntity.ok(Map.of( - "data", Map.of( - "userId", principal.userId(), - "displayName", principal.displayName(), - "email", principal.email() != null ? principal.email() : "", - "avatarUrl", principal.avatarUrl() != null ? principal.avatarUrl() : "", - "authType", principal.oauthProvider(), - "platformRoles", principal.platformRoles() - ) - )); + return ok("response.success.read", CliWhoamiResponse.from(principal)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java index b1225f8c..5d3bf3a1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java @@ -1,17 +1,22 @@ package com.iflytek.skillhub.controller; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - @RestController @RequestMapping("/api/v1") -public class HealthController { +public class HealthController extends BaseApiController { + + public HealthController(ApiResponseFactory responseFactory) { + super(responseFactory); + } @GetMapping("/health") - public Map health() { - return Map.of("status", "UP"); + public ApiResponse health() { + return ok("response.success.health", new MessageResponse("UP")); } } 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 7cd8fcb4..d1b8420b 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 @@ -2,63 +2,67 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; -import org.springframework.http.ResponseEntity; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.TokenCreateRequest; +import com.iflytek.skillhub.dto.TokenCreateResponse; +import com.iflytek.skillhub.dto.TokenSummaryResponse; +import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Map; @RestController @RequestMapping("/api/v1/tokens") -public class TokenController { +public class TokenController extends BaseApiController { private final ApiTokenService apiTokenService; - public TokenController(ApiTokenService apiTokenService) { + public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory) { + super(responseFactory); this.apiTokenService = apiTokenService; } @PostMapping - public ResponseEntity> create( + public ApiResponse create( @AuthenticationPrincipal PlatformPrincipal principal, - @RequestBody Map body) { - String name = (String) body.get("name"); - String scopeJson = body.containsKey("scopes") - ? body.get("scopes").toString() : "[\"skill:read\",\"skill:publish\"]"; + @Valid @RequestBody TokenCreateRequest request) { + String scopeJson = request.scopes() == null || request.scopes().isEmpty() + ? "[\"skill:read\",\"skill:publish\"]" + : request.scopes().toString(); - var result = apiTokenService.createToken(principal.userId(), name, scopeJson); - return ResponseEntity.ok(Map.of( - "data", Map.of( - "token", result.rawToken(), - "id", result.entity().getId(), - "name", result.entity().getName(), - "tokenPrefix", result.entity().getTokenPrefix(), - "createdAt", result.entity().getCreatedAt().toString(), - "expiresAt", result.entity().getExpiresAt() != null ? result.entity().getExpiresAt().toString() : "" - ) + var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson); + return ok("response.success.created", new TokenCreateResponse( + result.rawToken(), + result.entity().getId(), + result.entity().getName(), + result.entity().getTokenPrefix(), + result.entity().getCreatedAt().toString(), + result.entity().getExpiresAt() != null ? result.entity().getExpiresAt().toString() : "" )); } @GetMapping - public ResponseEntity list(@AuthenticationPrincipal PlatformPrincipal principal) { + public ApiResponse> list(@AuthenticationPrincipal PlatformPrincipal principal) { var tokens = apiTokenService.listActiveTokens(principal.userId()); - var result = tokens.stream().map(t -> Map.of( - "id", t.getId(), - "name", t.getName(), - "tokenPrefix", t.getTokenPrefix(), - "createdAt", t.getCreatedAt().toString(), - "expiresAt", t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", - "lastUsedAt", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" + var result = tokens.stream().map(t -> new TokenSummaryResponse( + t.getId(), + t.getName(), + t.getTokenPrefix(), + t.getCreatedAt().toString(), + t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", + t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" )).toList(); - return ResponseEntity.ok(Map.of("data", result)); + return ok("response.success.read", result); } @DeleteMapping("/{id}") - public ResponseEntity revoke( + public ApiResponse revoke( @AuthenticationPrincipal PlatformPrincipal principal, @PathVariable Long id) { apiTokenService.revokeToken(id, principal.userId()); - return ResponseEntity.noContent().build(); + return ok("response.success.revoked", new MessageResponse("Token revoked")); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index 92ec99a7..76bae215 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -1,16 +1,16 @@ package com.iflytek.skillhub.controller.cli; -import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.ratelimit.RateLimit; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -19,27 +19,29 @@ import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/cli") -public class CliPublishController { +public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; - public CliPublishController(SkillPublishService skillPublishService) { + public CliPublishController(SkillPublishService skillPublishService, + ApiResponseFactory responseFactory) { + super(responseFactory); this.skillPublishService = skillPublishService; } @PostMapping("/publish") @RateLimit(category = "publish", authenticated = 10, anonymous = 0) - public ResponseEntity publish( + public ApiResponse publish( @RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") Long userId) throws IOException { + @RequestAttribute("userId") String userId) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); List entries = extractZipEntries(file); - SkillVersion version = skillPublishService.publishFromEntries( + SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, entries, userId, @@ -47,16 +49,16 @@ public class CliPublishController { ); PublishResponse response = new PublishResponse( - version.getSkillId(), + publishResult.skillId(), namespace, - null, // slug will be extracted from metadata - version.getVersion(), - version.getStatus().name(), - version.getFileCount(), - version.getTotalSize() + publishResult.slug(), + publishResult.version().getVersion(), + publishResult.version().getStatus().name(), + publishResult.version().getFileCount(), + publishResult.version().getTotalSize() ); - return ResponseEntity.ok(response); + return ok("response.success.published", response); } private List extractZipEntries(MultipartFile file) throws IOException { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java new file mode 100644 index 00000000..9b4c8f75 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.service.MySkillAppService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/me") +public class MeController extends BaseApiController { + + private final MySkillAppService mySkillAppService; + + public MeController(MySkillAppService mySkillAppService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.mySkillAppService = mySkillAppService; + } + + @GetMapping("/skills") + public ApiResponse> listMySkills( + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMySkills(principal.userId())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 0206ea7f..d3995006 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.*; import com.iflytek.skillhub.dto.*; import jakarta.validation.Valid; @@ -8,11 +10,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequestMapping("/api/v1/namespaces") -public class NamespaceController { +public class NamespaceController extends BaseApiController { private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; @@ -20,97 +20,102 @@ public class NamespaceController { public NamespaceController(NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, - NamespaceRepository namespaceRepository) { + NamespaceRepository namespaceRepository, + ApiResponseFactory responseFactory) { + super(responseFactory); this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceRepository = namespaceRepository; } @GetMapping - public Map listNamespaces(Pageable pageable) { + public ApiResponse> listNamespaces(Pageable pageable) { Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); - Page response = namespaces.map(NamespaceResponse::from); - return Map.of("code", 0, "data", response); + PageResponse response = PageResponse.from(namespaces.map(NamespaceResponse::from)); + return ok("response.success.read", response); } @GetMapping("/{slug}") - public Map getNamespace(@PathVariable String slug) { + public ApiResponse getNamespace(@PathVariable String slug) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); - return Map.of("code", 0, "data", NamespaceResponse.from(namespace)); + return ok("response.success.read", NamespaceResponse.from(namespace)); } @PostMapping - public Map createNamespace( + public ApiResponse createNamespace( @Valid @RequestBody NamespaceRequest request, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal PlatformPrincipal principal) { Namespace namespace = namespaceService.createNamespace( request.slug(), request.displayName(), request.description(), - userId + principal.userId() ); - return Map.of("code", 0, "data", NamespaceResponse.from(namespace)); + return ok("response.success.created", NamespaceResponse.from(namespace)); } @PutMapping("/{slug}") - public Map updateNamespace( + public ApiResponse updateNamespace( @PathVariable String slug, - @RequestBody NamespaceRequest request) { + @RequestBody NamespaceRequest request, + @RequestAttribute("userId") String userId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); Namespace updated = namespaceService.updateNamespace( namespace.getId(), request.displayName(), request.description(), - null + null, + userId ); - return Map.of("code", 0, "data", NamespaceResponse.from(updated)); + return ok("response.success.updated", NamespaceResponse.from(updated)); } @GetMapping("/{slug}/members") - public Map listMembers(@PathVariable String slug, Pageable pageable) { + public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); - Page response = members.map(MemberResponse::from); - return Map.of("code", 0, "data", response); + PageResponse response = PageResponse.from(members.map(MemberResponse::from)); + return ok("response.success.read", response); } @PostMapping("/{slug}/members") - public Map addMember( + public ApiResponse addMember( @PathVariable String slug, - @Valid @RequestBody MemberRequest request) { + @Valid @RequestBody MemberRequest request, + @RequestAttribute("userId") String userId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.addMember( namespace.getId(), request.userId(), - request.role() + request.role(), + userId ); - return Map.of("code", 0, "data", MemberResponse.from(member)); + return ok("response.success.created", MemberResponse.from(member)); } @DeleteMapping("/{slug}/members/{userId}") - public Map removeMember( + public ApiResponse removeMember( @PathVariable String slug, - @PathVariable Long userId) { + @PathVariable("userId") String memberUserId, + @RequestAttribute("userId") String operatorUserId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.removeMember(namespace.getId(), userId); - return Map.of("code", 0, "data", "Member removed successfully"); + namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId); + return ok("response.success.deleted", new MessageResponse("Member removed successfully")); } @PutMapping("/{slug}/members/{userId}/role") - public Map updateMemberRole( + public ApiResponse updateMemberRole( @PathVariable String slug, - @PathVariable Long userId, - @RequestBody Map body) { + @PathVariable String userId, + @Valid @RequestBody UpdateMemberRoleRequest request, + @RequestAttribute("userId") String operatorUserId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); - NamespaceRole newRole = body.get("role"); - if (newRole == null) { - throw new IllegalArgumentException("Role is required"); - } NamespaceMember member = namespaceMemberService.updateMemberRole( namespace.getId(), userId, - newRole + request.role(), + operatorUserId ); - return Map.of("code", 0, "data", MemberResponse.from(member)); + return ok("response.success.updated", MemberResponse.from(member)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index bba5c0e6..c85d2cd0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -54,7 +54,7 @@ public class PromotionController extends BaseApiController { @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), request.targetNamespaceId(), userId); @@ -65,7 +65,7 @@ public class PromotionController extends BaseApiController { public ApiResponse approvePromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles); @@ -76,7 +76,7 @@ public class PromotionController extends BaseApiController { public ApiResponse rejectPromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles); @@ -87,7 +87,7 @@ public class PromotionController extends BaseApiController { public ApiResponse> listPendingPromotions( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); if (!hasAdminRole) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 966074ea..b5743988 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -56,7 +56,7 @@ public class ReviewController extends BaseApiController { @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { SkillVersion sv = skillVersionRepository.findById(request.skillVersionId()) .orElseThrow(); Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); @@ -68,7 +68,7 @@ public class ReviewController extends BaseApiController { public ApiResponse approveReview( @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") Long userId, + @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); @@ -81,7 +81,7 @@ public class ReviewController extends BaseApiController { public ApiResponse rejectReview( @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") Long userId, + @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); @@ -93,7 +93,7 @@ public class ReviewController extends BaseApiController { @PostMapping("/{id}/withdraw") public ApiResponse withdrawReview( @PathVariable Long id, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); reviewService.withdrawReview(task.getSkillVersionId(), userId); return ok("response.success.update", null); @@ -104,7 +104,7 @@ public class ReviewController extends BaseApiController { @RequestParam Long namespaceId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); @@ -114,7 +114,7 @@ public class ReviewController extends BaseApiController { public ApiResponse> listMySubmissions( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 76e239ff..2fba90df 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -1,13 +1,18 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillFile; -import com.iflytek.skillhub.domain.skill.SkillFileRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; +import com.iflytek.skillhub.dto.SkillVersionDetailResponse; import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.core.io.InputStreamResource; @@ -25,26 +30,25 @@ import java.util.stream.Collectors; @RestController @RequestMapping("/api/v1/skills") -public class SkillController { +public class SkillController extends BaseApiController { private final SkillQueryService skillQueryService; private final SkillDownloadService skillDownloadService; - private final SkillFileRepository skillFileRepository; public SkillController( SkillQueryService skillQueryService, SkillDownloadService skillDownloadService, - SkillFileRepository skillFileRepository) { + ApiResponseFactory responseFactory) { + super(responseFactory); this.skillQueryService = skillQueryService; this.skillDownloadService = skillDownloadService; - this.skillFileRepository = skillFileRepository; } @GetMapping("/{namespace}/{slug}") - public ResponseEntity getSkillDetail( + public ApiResponse getSkillDetail( @PathVariable String namespace, @PathVariable String slug, - @RequestAttribute(value = "userId", required = false) Long userId, + @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( @@ -63,11 +67,11 @@ public class SkillController { namespace ); - return ResponseEntity.ok(response); + return ok("response.success.read", response); } @GetMapping("/{namespace}/{slug}/versions") - public ResponseEntity> listVersions( + public ApiResponse> listVersions( @PathVariable String namespace, @PathVariable String slug, @RequestParam(defaultValue = "0") int page, @@ -76,7 +80,7 @@ public class SkillController { Page versions = skillQueryService.listVersions( namespace, slug, PageRequest.of(page, size)); - Page response = versions.map(v -> new SkillVersionResponse( + PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), v.getVersion(), v.getStatus().name(), @@ -84,18 +88,56 @@ public class SkillController { v.getFileCount(), v.getTotalSize(), v.getPublishedAt() - )); + ))); - return ResponseEntity.ok(response); + return ok("response.success.read", response); + } + + @GetMapping("/{namespace}/{slug}/versions/{version}") + public ApiResponse getVersionDetail( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillQueryService.SkillVersionDetailDTO detail = skillQueryService.getVersionDetail( + namespace, + slug, + version, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + SkillVersionDetailResponse response = new SkillVersionDetailResponse( + detail.id(), + detail.version(), + detail.status(), + detail.changelog(), + detail.fileCount(), + detail.totalSize(), + detail.publishedAt(), + detail.parsedMetadataJson(), + detail.manifestJson() + ); + return ok("response.success.read", response); } @GetMapping("/{namespace}/{slug}/versions/{version}/files") - public ResponseEntity> listFiles( + public ApiResponse> listFiles( @PathVariable String namespace, @PathVariable String slug, - @PathVariable String version) { + @PathVariable String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - List files = skillQueryService.listFiles(namespace, slug, version); + List files = skillQueryService.listFiles( + namespace, + slug, + version, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); List response = files.stream() .map(f -> new SkillFileResponse( @@ -107,7 +149,36 @@ public class SkillController { )) .collect(Collectors.toList()); - return ResponseEntity.ok(response); + return ok("response.success.read", response); + } + + @GetMapping("/{namespace}/{slug}/tags/{tagName}/files") + public ApiResponse> listFilesByTag( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String tagName, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + List files = skillQueryService.listFilesByTag( + namespace, + slug, + tagName, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + List response = files.stream() + .map(f -> new SkillFileResponse( + f.getId(), + f.getFilePath(), + f.getFileSize(), + f.getContentType(), + f.getSha256() + )) + .collect(Collectors.toList()); + + return ok("response.success.read", response); } @GetMapping("/{namespace}/{slug}/versions/{version}/file") @@ -115,21 +186,87 @@ public class SkillController { @PathVariable String namespace, @PathVariable String slug, @PathVariable String version, - @RequestParam("path") String path) { + @RequestParam("path") String path, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - InputStream content = skillQueryService.getFileContent(namespace, slug, version, path); + InputStream content = skillQueryService.getFileContent( + namespace, + slug, + version, + path, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(new InputStreamResource(content)); } + @GetMapping("/{namespace}/{slug}/tags/{tagName}/file") + public ResponseEntity getFileContentByTag( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String tagName, + @RequestParam("path") String path, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + InputStream content = skillQueryService.getFileContentByTag( + namespace, + slug, + tagName, + path, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(new InputStreamResource(content)); + } + + @GetMapping("/{namespace}/{slug}/resolve") + public ApiResponse resolveVersion( + @PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + namespace, + slug, + version, + tag, + hash, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + ResolveVersionResponse response = new ResolveVersionResponse( + resolved.skillId(), + resolved.namespace(), + resolved.slug(), + resolved.version(), + resolved.versionId(), + resolved.fingerprint(), + resolved.matched(), + resolved.downloadUrl() + ); + + return ok("response.success.read", response); + } + @GetMapping("/{namespace}/{slug}/download") @RateLimit(category = "download", authenticated = 120, anonymous = 30) public ResponseEntity downloadLatest( @PathVariable String namespace, @PathVariable String slug, - @RequestAttribute(value = "userId", required = false) Long userId, + @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( @@ -148,7 +285,7 @@ public class SkillController { @PathVariable String namespace, @PathVariable String slug, @PathVariable String version, - @RequestAttribute(value = "userId", required = false) Long userId, + @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( @@ -160,4 +297,23 @@ public class SkillController { .contentLength(result.contentLength()) .body(new InputStreamResource(result.content())); } + + @GetMapping("/{namespace}/{slug}/tags/{tagName}/download") + @RateLimit(category = "download", authenticated = 120, anonymous = 30) + public ResponseEntity downloadByTag( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String tagName, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( + namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") + .contentType(MediaType.parseMediaType(result.contentType())) + .contentLength(result.contentLength()) + .body(new InputStreamResource(result.content())); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index 22fc3648..e984e31e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,16 +1,16 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.ratelimit.RateLimit; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -19,27 +19,29 @@ import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/skills") -public class SkillPublishController { +public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; - public SkillPublishController(SkillPublishService skillPublishService) { + public SkillPublishController(SkillPublishService skillPublishService, + ApiResponseFactory responseFactory) { + super(responseFactory); this.skillPublishService = skillPublishService; } @PostMapping("/{namespace}/publish") @RateLimit(category = "publish", authenticated = 10, anonymous = 0) - public ResponseEntity publish( + public ApiResponse publish( @PathVariable String namespace, @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") Long userId) throws IOException { + @RequestAttribute("userId") String userId) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); List entries = extractZipEntries(file); - SkillVersion version = skillPublishService.publishFromEntries( + SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, entries, userId, @@ -47,16 +49,16 @@ public class SkillPublishController { ); PublishResponse response = new PublishResponse( - version.getSkillId(), + publishResult.skillId(), namespace, - null, // slug will be extracted from metadata - version.getVersion(), - version.getStatus().name(), - version.getFileCount(), - version.getTotalSize() + publishResult.slug(), + publishResult.version().getVersion(), + publishResult.version().getStatus().name(), + publishResult.version().getFileCount(), + publishResult.version().getTotalSize() ); - return ResponseEntity.ok(response); + return ok("response.success.published", response); } private List extractZipEntries(MultipartFile file) throws IOException { 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 901962e8..505229ee 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 @@ -1,32 +1,36 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/v1/skills") -public class SkillSearchController { +public class SkillSearchController extends BaseApiController { private final SkillSearchAppService skillSearchAppService; - public SkillSearchController(SkillSearchAppService skillSearchAppService) { + public SkillSearchController(SkillSearchAppService skillSearchAppService, + ApiResponseFactory responseFactory) { + super(responseFactory); this.skillSearchAppService = skillSearchAppService; } @GetMapping @RateLimit(category = "search", authenticated = 60, anonymous = 20) - public ResponseEntity search( + public ApiResponse search( @RequestParam(required = false) String q, - @RequestParam(required = false) Long namespace, + @RequestParam(required = false) String namespace, @RequestParam(defaultValue = "newest") String sort, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute(value = "userId", required = false) Long userId, + @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillSearchAppService.SearchResponse response = skillSearchAppService.search( @@ -39,6 +43,6 @@ public class SkillSearchController { userNsRoles ); - return ResponseEntity.ok(response); + return ok("response.success.read", response); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index 1e4ea459..1882dac8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -1,11 +1,14 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.skill.SkillTag; import com.iflytek.skillhub.domain.skill.service.SkillTagService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.dto.TagRequest; import com.iflytek.skillhub.dto.TagResponse; import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -13,16 +16,18 @@ import java.util.stream.Collectors; @RestController @RequestMapping("/api/v1/skills/{namespace}/{slug}/tags") -public class SkillTagController { +public class SkillTagController extends BaseApiController { private final SkillTagService skillTagService; - public SkillTagController(SkillTagService skillTagService) { + public SkillTagController(SkillTagService skillTagService, + ApiResponseFactory responseFactory) { + super(responseFactory); this.skillTagService = skillTagService; } @GetMapping - public ResponseEntity> listTags( + public ApiResponse> listTags( @PathVariable String namespace, @PathVariable String slug) { @@ -32,16 +37,16 @@ public class SkillTagController { .map(TagResponse::from) .collect(Collectors.toList()); - return ResponseEntity.ok(response); + return ok("response.success.read", response); } @PutMapping("/{tagName}") - public ResponseEntity createOrMoveTag( + public ApiResponse createOrMoveTag( @PathVariable String namespace, @PathVariable String slug, @PathVariable String tagName, @Valid @RequestBody TagRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { SkillTag tag = skillTagService.createOrMoveTag( namespace, @@ -51,18 +56,18 @@ public class SkillTagController { userId ); - return ResponseEntity.ok(TagResponse.from(tag)); + return ok("response.success.updated", TagResponse.from(tag)); } @DeleteMapping("/{tagName}") - public ResponseEntity deleteTag( + public ApiResponse deleteTag( @PathVariable String namespace, @PathVariable String slug, @PathVariable String tagName, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { skillTagService.deleteTag(namespace, slug, tagName, userId); - return ResponseEntity.noContent().build(); + return ok("response.success.deleted", new MessageResponse("Tag deleted")); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponse.java new file mode 100644 index 00000000..d23fbcbd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record ApiResponse( + int code, + String msg, + T data, + Instant timestamp, + String requestId +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java new file mode 100644 index 00000000..fa4abf81 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub.dto; + +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Component; +import org.slf4j.MDC; +import org.springframework.context.i18n.LocaleContextHolder; + +import java.time.Instant; + +@Component +public class ApiResponseFactory { + + private final MessageSource messageSource; + + public ApiResponseFactory(MessageSource messageSource) { + this.messageSource = messageSource; + } + + public ApiResponse ok(String messageCode, T data, Object... args) { + String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); + return new ApiResponse<>(0, msg, data, Instant.now(), MDC.get("requestId")); + } + + public ApiResponse error(int code, String messageCode, Object... args) { + String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); + return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId")); + } + + public ApiResponse errorMessage(int code, String msg) { + return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId")); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java new file mode 100644 index 00000000..470b2fdb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +import java.util.Set; + +public record AuthMeResponse( + String userId, + String displayName, + String email, + String avatarUrl, + String oauthProvider, + Set platformRoles +) { + public static AuthMeResponse from(PlatformPrincipal principal) { + return new AuthMeResponse( + principal.userId(), + principal.displayName(), + principal.email() != null ? principal.email() : "", + principal.avatarUrl() != null ? principal.avatarUrl() : "", + principal.oauthProvider(), + principal.platformRoles() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthProviderResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthProviderResponse.java new file mode 100644 index 00000000..ffa3ecd9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthProviderResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +public record AuthProviderResponse( + String id, + String name, + String authorizationUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CliWhoamiResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CliWhoamiResponse.java new file mode 100644 index 00000000..2e72f76a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CliWhoamiResponse.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +import java.util.Set; + +public record CliWhoamiResponse( + String userId, + String displayName, + String email, + String avatarUrl, + String authType, + Set platformRoles +) { + public static CliWhoamiResponse from(PlatformPrincipal principal) { + return new CliWhoamiResponse( + principal.userId(), + principal.displayName(), + principal.email() != null ? principal.email() : "", + principal.avatarUrl() != null ? principal.avatarUrl() : "", + principal.oauthProvider(), + principal.platformRoles() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java deleted file mode 100644 index 66a91977..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.iflytek.skillhub.dto; - -import java.time.Instant; - -public record ErrorResponse( - int status, - String error, - String message, - String requestId, - Instant timestamp -) { - public ErrorResponse(int status, String error, String message, String requestId) { - this(status, error, message, requestId, Instant.now()); - } - - public ErrorResponse(int status, String error, String message) { - this(status, error, message, null, Instant.now()); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberRequest.java index 942e51fa..71c320dc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberRequest.java @@ -4,9 +4,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import jakarta.validation.constraints.NotNull; public record MemberRequest( - @NotNull(message = "User ID cannot be null") - Long userId, + @NotNull(message = "{validation.member.userId.notNull}") + String userId, - @NotNull(message = "Role cannot be null") + @NotNull(message = "{validation.member.role.notNull}") NamespaceRole role ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java index 0ed60f68..39c24462 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java @@ -8,7 +8,7 @@ import java.time.LocalDateTime; public record MemberResponse( Long id, Long namespaceId, - Long userId, + String userId, NamespaceRole role, LocalDateTime createdAt, LocalDateTime updatedAt diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MessageResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MessageResponse.java new file mode 100644 index 00000000..f2a7bce0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MessageResponse.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +public record MessageResponse( + String message +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java index 7a0bcfab..7e645675 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java @@ -4,14 +4,14 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record NamespaceRequest( - @NotBlank(message = "Slug cannot be blank") - @Size(min = 2, max = 64, message = "Slug must be between 2 and 64 characters") + @NotBlank(message = "{validation.namespace.slug.notBlank}") + @Size(min = 2, max = 64, message = "{validation.namespace.slug.size}") String slug, - @NotBlank(message = "Display name cannot be blank") - @Size(max = 128, message = "Display name must not exceed 128 characters") + @NotBlank(message = "{validation.namespace.displayName.notBlank}") + @Size(max = 128, message = "{validation.namespace.displayName.size}") String displayName, - @Size(max = 512, message = "Description must not exceed 512 characters") + @Size(max = 512, message = "{validation.namespace.description.size}") String description ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java index 747aca5e..81158662 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java @@ -14,7 +14,7 @@ public record NamespaceResponse( String description, NamespaceType type, String avatarUrl, - Long createdBy, + String createdBy, LocalDateTime createdAt, LocalDateTime updatedAt ) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PageResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PageResponse.java new file mode 100644 index 00000000..b266d61e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PageResponse.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse( + List items, + long total, + int page, + int size +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getTotalElements(), + page.getNumber(), + page.getSize() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java index c967a526..888f447f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java @@ -11,9 +11,9 @@ public record PromotionResponseDto( String targetNamespace, Long targetSkillId, String status, - Long submittedBy, + String submittedBy, String submittedByName, - Long reviewedBy, + String reviewedBy, String reviewedByName, String reviewComment, Instant submittedAt, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResolveVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResolveVersionResponse.java new file mode 100644 index 00000000..544d688c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResolveVersionResponse.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +public record ResolveVersionResponse( + Long skillId, + String namespace, + String slug, + String version, + Long versionId, + String fingerprint, + Boolean matched, + String downloadUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java index 1f42a8d3..6ea9c896 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java @@ -9,9 +9,9 @@ public record ReviewTaskResponse( String skillSlug, String version, String status, - Long submittedBy, + String submittedBy, String submittedByName, - Long reviewedBy, + String reviewedBy, String reviewedByName, String reviewComment, Instant submittedAt, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 9bf77a88..99b7cf76 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -1,11 +1,18 @@ package com.iflytek.skillhub.dto; +import java.math.BigDecimal; +import java.time.LocalDateTime; + public record SkillSummaryResponse( Long id, String slug, String displayName, String summary, Long downloadCount, + Integer starCount, + BigDecimal ratingAvg, + Integer ratingCount, String latestVersion, - String namespace + String namespace, + LocalDateTime updatedAt ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java new file mode 100644 index 00000000..c80f2a7b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.dto; + +import java.time.LocalDateTime; + +public record SkillVersionDetailResponse( + Long id, + String version, + String status, + String changelog, + int fileCount, + long totalSize, + LocalDateTime publishedAt, + String parsedMetadataJson, + String manifestJson +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java new file mode 100644 index 00000000..63eadf18 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +public record TokenCreateRequest( + @NotBlank(message = "{validation.token.name.notBlank}") + String name, + List scopes +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateResponse.java new file mode 100644 index 00000000..3911dae1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +public record TokenCreateResponse( + String token, + Long id, + String name, + String tokenPrefix, + String createdAt, + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenSummaryResponse.java new file mode 100644 index 00000000..f2825f3b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenSummaryResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +public record TokenSummaryResponse( + Long id, + String name, + String tokenPrefix, + String createdAt, + String expiresAt, + String lastUsedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateMemberRoleRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateMemberRoleRequest.java new file mode 100644 index 00000000..036c994d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateMemberRoleRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import jakarta.validation.constraints.NotNull; + +public record UpdateMemberRoleRequest( + @NotNull(message = "{validation.member.role.notNull}") + NamespaceRole role +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index c74d22a3..c1e91628 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -1,12 +1,16 @@ package com.iflytek.skillhub.exception; -import com.iflytek.skillhub.dto.ErrorResponse; -import jakarta.servlet.http.HttpServletRequest; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -14,22 +18,63 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private final ApiResponseFactory apiResponseFactory; + + public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory) { + this.apiResponseFactory = apiResponseFactory; + } + + @ExceptionHandler(LocalizedException.class) + public ResponseEntity> handleLocalizedError(LocalizedException ex) { + HttpStatus status = ex.status(); + return ResponseEntity.status(status).body( + apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); + } + + @ExceptionHandler(DomainBadRequestException.class) + public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { + return ResponseEntity.badRequest().body( + apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs())); + } + + @ExceptionHandler(DomainForbiddenException.class) + public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body( + apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String msg = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElseGet(() -> ex.getBindingResult().getAllErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse(null)); + if (msg == null || msg.isBlank()) { + return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest")); + } + return ResponseEntity.badRequest().body(apiResponseFactory.errorMessage(400, msg)); + } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleBadRequest(IllegalArgumentException ex, - HttpServletRequest request) { - String requestId = MDC.get("requestId"); + public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { return ResponseEntity.badRequest().body( - new ErrorResponse(400, "Bad Request", ex.getMessage(), requestId)); + apiResponseFactory.error(400, "error.badRequest")); + } + + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleForbidden(SecurityException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body( + apiResponseFactory.error(403, "error.forbidden")); } @ExceptionHandler(Exception.class) - public ResponseEntity handleGlobalException(Exception ex, - HttpServletRequest request) { + public ResponseEntity> handleGlobalException(Exception ex) { String requestId = MDC.get("requestId"); logger.error("Unhandled exception [requestId={}]", requestId, ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - new ErrorResponse(500, "Internal Server Error", - "An unexpected error occurred", requestId)); + apiResponseFactory.error(500, "error.internal")); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java new file mode 100644 index 00000000..54622ed3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public interface LocalizedError { + String messageCode(); + + Object[] messageArgs(); + + HttpStatus status(); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java new file mode 100644 index 00000000..5d41abe5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public abstract class LocalizedException extends RuntimeException implements LocalizedError { + + private final String messageCode; + private final Object[] messageArgs; + + protected LocalizedException(String messageCode, Object... messageArgs) { + super(messageCode); + this.messageCode = messageCode; + this.messageArgs = messageArgs; + } + + @Override + public String messageCode() { + return messageCode; + } + + @Override + public Object[] messageArgs() { + return messageArgs; + } + + @Override + public abstract HttpStatus status(); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java new file mode 100644 index 00000000..7be50e3b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/UnauthorizedException.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public class UnauthorizedException extends LocalizedException { + + public UnauthorizedException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } + + @Override + public HttpStatus status() { + return HttpStatus.UNAUTHORIZED; + } +} 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 new file mode 100644 index 00000000..60d7ac6d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -0,0 +1,64 @@ +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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class AuthContextFilter extends OncePerRequestFilter { + + private final NamespaceMemberRepository namespaceMemberRepository; + + public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository) { + this.namespaceMemberRepository = namespaceMemberRepository; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + PlatformPrincipal principal = resolvePrincipal(request); + if (principal != null) { + request.setAttribute("userId", principal.userId()); + Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() + .collect(Collectors.toMap( + NamespaceMember::getNamespaceId, + NamespaceMember::getRole, + (left, right) -> left)); + request.setAttribute("userNsRoles", userNsRoles); + } + + filterChain.doFilter(request, response); + } + + private PlatformPrincipal resolvePrincipal(HttpServletRequest request) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null) { + Object principal = authentication.getPrincipal(); + if (principal instanceof PlatformPrincipal platformPrincipal) { + return platformPrincipal; + } + } + + Object sessionPrincipal = request.getSession(false) != null + ? request.getSession(false).getAttribute("platformPrincipal") + : null; + if (sessionPrincipal instanceof PlatformPrincipal platformPrincipal) { + return platformPrincipal; + } + return null; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java new file mode 100644 index 00000000..eeef6e7e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillRatingEventListener.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.social.SkillRatingRepository; +import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class SkillRatingEventListener { + private final JdbcTemplate jdbcTemplate; + private final SkillRatingRepository ratingRepository; + + public SkillRatingEventListener(JdbcTemplate jdbcTemplate, SkillRatingRepository ratingRepository) { + this.jdbcTemplate = jdbcTemplate; + this.ratingRepository = ratingRepository; + } + + @Async + @TransactionalEventListener + public void onRated(SkillRatedEvent event) { + double avg = ratingRepository.averageScoreBySkillId(event.skillId()); + int count = ratingRepository.countBySkillId(event.skillId()); + jdbcTemplate.update( + "UPDATE skill SET rating_avg = ?, rating_count = ? WHERE id = ?", + avg, count, event.skillId()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java new file mode 100644 index 00000000..903041f2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillStarEventListener.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.social.SkillStarRepository; +import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; +import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class SkillStarEventListener { + private final JdbcTemplate jdbcTemplate; + private final SkillStarRepository starRepository; + + public SkillStarEventListener(JdbcTemplate jdbcTemplate, SkillStarRepository starRepository) { + this.jdbcTemplate = jdbcTemplate; + this.starRepository = starRepository; + } + + @Async + @TransactionalEventListener + public void onStarred(SkillStarredEvent event) { + updateStarCount(event.skillId()); + } + + @Async + @TransactionalEventListener + public void onUnstarred(SkillUnstarredEvent event) { + updateStarCount(event.skillId()); + } + + private void updateStarCount(Long skillId) { + long count = starRepository.countBySkillId(skillId); + jdbcTemplate.update("UPDATE skill SET star_count = ? WHERE id = ?", (int) count, skillId); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java new file mode 100644 index 00000000..89f8be79 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/InMemorySlidingWindowRateLimiter.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.ratelimit; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.Deque; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +@Component +@Profile("test") +public class InMemorySlidingWindowRateLimiter implements RateLimiter { + + private final ConcurrentHashMap> requests = new ConcurrentHashMap<>(); + + @Override + public boolean tryAcquire(String key, int limit, int windowSeconds) { + long now = System.currentTimeMillis(); + long windowMillis = windowSeconds * 1000L; + Deque timestamps = requests.computeIfAbsent(key, ignored -> new ConcurrentLinkedDeque<>()); + + synchronized (timestamps) { + evictExpired(timestamps, now - windowMillis); + if (timestamps.size() >= limit) { + return false; + } + timestamps.addLast(now); + return true; + } + } + + private void evictExpired(Deque timestamps, long threshold) { + while (!timestamps.isEmpty()) { + Long oldest = timestamps.peekFirst(); + if (oldest == null || oldest >= threshold) { + return; + } + timestamps.pollFirst(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java index 045bf338..f1cf5ef7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java @@ -1,8 +1,12 @@ package com.iflytek.skillhub.ratelimit; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; @@ -10,10 +14,16 @@ import org.springframework.web.servlet.HandlerInterceptor; @Component public class RateLimitInterceptor implements HandlerInterceptor { - private final SlidingWindowRateLimiter rateLimiter; + private final RateLimiter rateLimiter; + private final ApiResponseFactory apiResponseFactory; + private final ObjectMapper objectMapper; - public RateLimitInterceptor(SlidingWindowRateLimiter rateLimiter) { + public RateLimitInterceptor(RateLimiter rateLimiter, + ApiResponseFactory apiResponseFactory, + ObjectMapper objectMapper) { this.rateLimiter = rateLimiter; + this.apiResponseFactory = apiResponseFactory; + this.objectMapper = objectMapper; } @Override @@ -30,7 +40,7 @@ public class RateLimitInterceptor implements HandlerInterceptor { } // Determine if user is authenticated - Long userId = (Long) request.getAttribute("userId"); + String userId = (String) request.getAttribute("userId"); boolean isAuthenticated = userId != null; // Get limit based on authentication status @@ -45,8 +55,9 @@ public class RateLimitInterceptor implements HandlerInterceptor { if (!allowed) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); - response.setContentType("application/json"); - response.getWriter().write("{\"error\":\"Rate limit exceeded\"}"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + ApiResponse body = apiResponseFactory.error(429, "error.rateLimit.exceeded"); + objectMapper.writeValue(response.getOutputStream(), body); return false; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java new file mode 100644 index 00000000..0d003ab3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimiter.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.ratelimit; + +public interface RateLimiter { + + boolean tryAcquire(String key, int limit, int windowSeconds); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/SlidingWindowRateLimiter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java similarity index 85% rename from server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/SlidingWindowRateLimiter.java rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java index df078e93..7117b9bc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/SlidingWindowRateLimiter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RedisSlidingWindowRateLimiter.java @@ -1,21 +1,23 @@ package com.iflytek.skillhub.ratelimit; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Profile; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; import java.util.Collections; @Component -public class SlidingWindowRateLimiter { +@Profile("!test") +public class RedisSlidingWindowRateLimiter implements RateLimiter { private final StringRedisTemplate redisTemplate; private DefaultRedisScript rateLimitScript; - public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) { + public RedisSlidingWindowRateLimiter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @@ -26,6 +28,7 @@ public class SlidingWindowRateLimiter { rateLimitScript.setResultType(Long.class); } + @Override public boolean tryAcquire(String key, int limit, int windowSeconds) { long now = System.currentTimeMillis(); long windowMillis = windowSeconds * 1000L; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java new file mode 100644 index 00000000..a5d36a73 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class ApiAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + private final ApiResponseFactory apiResponseFactory; + + public ApiAccessDeniedHandler(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) { + this.objectMapper = objectMapper; + this.apiResponseFactory = apiResponseFactory; + } + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java new file mode 100644 index 00000000..d9bfe089 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + private final ApiResponseFactory apiResponseFactory; + + public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) { + this.objectMapper = objectMapper; + this.apiResponseFactory = apiResponseFactory; + } + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + ApiResponse body = apiResponseFactory.error(401, "error.auth.required"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java new file mode 100644 index 00000000..b262621f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +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.dto.SkillSummaryResponse; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class MySkillAppService { + + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; + + public MySkillAppService( + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository) { + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; + } + + public List listMySkills(String userId) { + List skills = skillRepository.findByOwnerId(userId).stream() + .sorted(Comparator.comparing(Skill::getUpdatedAt).reversed()) + .toList(); + + List latestVersionIds = skills.stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = latestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List namespaceIds = skills.stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespaceSlugsById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap( + com.iflytek.skillhub.domain.namespace.Namespace::getId, + com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + + return skills.stream() + .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .toList(); + } + + private SkillSummaryResponse toSummaryResponse( + Skill skill, + Map versionsById, + Map namespaceSlugsById) { + String latestVersion = skill.getLatestVersionId() == null + ? null + : Optional.ofNullable(versionsById.get(skill.getLatestVersionId())) + .map(SkillVersion::getVersion) + .orElse(null); + + return new SkillSummaryResponse( + skill.getId(), + skill.getSlug(), + skill.getDisplayName(), + skill.getSummary(), + skill.getDownloadCount(), + skill.getStarCount(), + skill.getRatingAvg(), + skill.getRatingCount(), + latestVersion, + namespaceSlugsById.get(skill.getNamespaceId()), + skill.getUpdatedAt() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 6a89172a..3a316f7b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -1,8 +1,12 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; 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.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -10,26 +14,33 @@ import com.iflytek.skillhub.search.SearchResult; import com.iflytek.skillhub.search.SearchVisibilityScope; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Service public class SkillSearchAppService { private final SearchQueryService searchQueryService; private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; public SkillSearchAppService( SearchQueryService searchQueryService, - SkillRepository skillRepository) { + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; } public record SearchResponse( - List skills, + List items, long total, int page, int size @@ -37,13 +48,15 @@ public class SkillSearchAppService { public SearchResponse search( String keyword, - Long namespaceId, + String namespaceSlug, String sortBy, int page, int size, - Long userId, + String userId, Map userNsRoles) { + Long namespaceId = resolveNamespaceId(namespaceSlug); + SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); SearchQuery query = new SearchQuery( @@ -56,18 +69,51 @@ public class SkillSearchAppService { ); SearchResult result = searchQueryService.search(query); + List matchedSkills = result.skillIds().isEmpty() + ? List.of() + : skillRepository.findByIdIn(result.skillIds()); + Map skillsById = matchedSkills.stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); - List skills = new ArrayList<>(); - for (Long skillId : result.skillIds()) { - skillRepository.findById(skillId).ifPresent(skill -> { - skills.add(toSummaryResponse(skill)); - }); - } + List latestVersionIds = matchedSkills.stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = latestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List namespaceIds = matchedSkills.stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespaceSlugsById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, + com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + + List skills = result.skillIds().stream() + .map(skillsById::get) + .filter(java.util.Objects::nonNull) + .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .toList(); return new SearchResponse(skills, result.total(), result.page(), result.size()); } - private SearchVisibilityScope buildVisibilityScope(Long userId, Map userNsRoles) { + private Long resolveNamespaceId(String namespaceSlug) { + if (namespaceSlug == null || namespaceSlug.isBlank()) { + return null; + } + return namespaceRepository.findBySlug(namespaceSlug) + .map(com.iflytek.skillhub.domain.namespace.Namespace::getId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); + } + + private SearchVisibilityScope buildVisibilityScope(String userId, Map userNsRoles) { if (userId == null || userNsRoles == null) { return SearchVisibilityScope.anonymous(); } @@ -77,21 +123,37 @@ public class SkillSearchAppService { .filter(e -> e.getValue() == NamespaceRole.ADMIN) .map(Map.Entry::getKey) .collect(java.util.stream.Collectors.toSet()); + adminNamespaceIds.addAll(userNsRoles.entrySet().stream() + .filter(e -> e.getValue() == NamespaceRole.OWNER) + .map(Map.Entry::getKey) + .toList()); return new SearchVisibilityScope(userId, memberNamespaceIds, adminNamespaceIds); } - private SkillSummaryResponse toSummaryResponse(Skill skill) { - // We need to get namespace slug and latest version - // For now, we'll use placeholders + private SkillSummaryResponse toSummaryResponse( + Skill skill, + Map versionsById, + Map namespaceSlugsById) { + String latestVersion = skill.getLatestVersionId() == null + ? null + : java.util.Optional.ofNullable(versionsById.get(skill.getLatestVersionId())) + .map(SkillVersion::getVersion) + .orElse(null); + String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId()); + return new SkillSummaryResponse( skill.getId(), skill.getSlug(), skill.getDisplayName(), skill.getSummary(), skill.getDownloadCount(), - null, // latestVersion - would need to query SkillVersion - null // namespace - would need to query Namespace + skill.getStarCount(), + skill.getRatingAvg(), + skill.getRatingCount(), + latestVersion, + namespaceSlug, + skill.getUpdatedAt() ); } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 33c6659a..6dc1aa03 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -3,6 +3,8 @@ server: shutdown: graceful spring: + messages: + basename: messages application: name: skillhub lifecycle: diff --git a/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql b/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql index 86b4d9b4..e291e59a 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql @@ -2,12 +2,12 @@ -- 用户账号表 CREATE TABLE user_account ( - id BIGSERIAL PRIMARY KEY, + id VARCHAR(128) PRIMARY KEY, display_name VARCHAR(128) NOT NULL, email VARCHAR(256), avatar_url VARCHAR(512), status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', - merged_to_user_id BIGINT, + merged_to_user_id VARCHAR(128), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -18,7 +18,7 @@ CREATE INDEX idx_user_account_status ON user_account(status); -- OAuth 身份绑定表 CREATE TABLE identity_binding ( id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), provider_code VARCHAR(64) NOT NULL, subject VARCHAR(256) NOT NULL, login_name VARCHAR(128), @@ -34,8 +34,8 @@ CREATE INDEX idx_identity_binding_user_id ON identity_binding(user_id); CREATE TABLE api_token ( id BIGSERIAL PRIMARY KEY, subject_type VARCHAR(32) NOT NULL DEFAULT 'USER', - subject_id BIGINT NOT NULL, - user_id BIGINT NOT NULL REFERENCES user_account(id), + subject_id VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), name VARCHAR(128) NOT NULL, token_prefix VARCHAR(16) NOT NULL, token_hash VARCHAR(64) NOT NULL UNIQUE, @@ -77,7 +77,7 @@ CREATE TABLE role_permission ( -- 用户角色绑定表 CREATE TABLE user_role_binding ( id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), role_id BIGINT NOT NULL REFERENCES role(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, role_id) @@ -94,7 +94,7 @@ CREATE TABLE namespace ( description TEXT, avatar_url VARCHAR(512), status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -103,7 +103,7 @@ CREATE TABLE namespace ( CREATE TABLE namespace_member ( id BIGSERIAL PRIMARY KEY, namespace_id BIGINT NOT NULL REFERENCES namespace(id), - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), role VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -116,7 +116,7 @@ CREATE INDEX idx_namespace_member_namespace_id ON namespace_member(namespace_id) -- 审计日志表 CREATE TABLE audit_log ( id BIGSERIAL PRIMARY KEY, - actor_user_id BIGINT REFERENCES user_account(id), + actor_user_id VARCHAR(128) REFERENCES user_account(id), action VARCHAR(64) NOT NULL, target_type VARCHAR(64), target_id BIGINT, diff --git a/server/skillhub-app/src/main/resources/db/migration/V2__phase2_skill_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V2__phase2_skill_tables.sql index dc3b286d..360b3a05 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V2__phase2_skill_tables.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V2__phase2_skill_tables.sql @@ -8,7 +8,7 @@ CREATE TABLE skill ( slug VARCHAR(128) NOT NULL, display_name VARCHAR(256), summary VARCHAR(512), - owner_id BIGINT NOT NULL REFERENCES user_account(id), + owner_id VARCHAR(128) NOT NULL REFERENCES user_account(id), source_skill_id BIGINT, visibility VARCHAR(32) NOT NULL DEFAULT 'PUBLIC', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', @@ -17,9 +17,9 @@ CREATE TABLE skill ( star_count INT NOT NULL DEFAULT 0, rating_avg DECIMAL(3,2) NOT NULL DEFAULT 0.00, rating_count INT NOT NULL DEFAULT 0, - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_by BIGINT REFERENCES user_account(id), + updated_by VARCHAR(128) REFERENCES user_account(id), updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(namespace_id, slug) ); @@ -38,7 +38,7 @@ CREATE TABLE skill_version ( file_count INT NOT NULL DEFAULT 0, total_size BIGINT NOT NULL DEFAULT 0, published_at TIMESTAMP, - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, version) ); @@ -67,7 +67,7 @@ CREATE TABLE skill_tag ( skill_id BIGINT NOT NULL REFERENCES skill(id), tag_name VARCHAR(64) NOT NULL, version_id BIGINT NOT NULL REFERENCES skill_version(id), - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, tag_name) @@ -79,7 +79,7 @@ CREATE TABLE skill_search_document ( skill_id BIGINT NOT NULL UNIQUE REFERENCES skill(id), namespace_id BIGINT NOT NULL, namespace_slug VARCHAR(64) NOT NULL, - owner_id BIGINT NOT NULL, + owner_id VARCHAR(128) NOT NULL, title VARCHAR(256), summary VARCHAR(512), keywords VARCHAR(512), diff --git a/server/skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql index d15b06e8..c6aa9a12 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql @@ -8,8 +8,8 @@ CREATE TABLE review_task ( namespace_id BIGINT NOT NULL REFERENCES namespace(id), status VARCHAR(32) NOT NULL DEFAULT 'PENDING', version INT NOT NULL DEFAULT 1, - submitted_by BIGINT NOT NULL REFERENCES user_account(id), - reviewed_by BIGINT REFERENCES user_account(id), + submitted_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + reviewed_by VARCHAR(128) REFERENCES user_account(id), review_comment TEXT, submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, reviewed_at TIMESTAMP @@ -28,8 +28,8 @@ CREATE TABLE promotion_request ( target_skill_id BIGINT REFERENCES skill(id), status VARCHAR(32) NOT NULL DEFAULT 'PENDING', version INT NOT NULL DEFAULT 1, - submitted_by BIGINT NOT NULL REFERENCES user_account(id), - reviewed_by BIGINT REFERENCES user_account(id), + submitted_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + reviewed_by VARCHAR(128) REFERENCES user_account(id), review_comment TEXT, submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, reviewed_at TIMESTAMP @@ -43,7 +43,7 @@ CREATE UNIQUE INDEX idx_promotion_request_version_pending ON promotion_request(s CREATE TABLE skill_star ( id BIGSERIAL PRIMARY KEY, skill_id BIGINT NOT NULL REFERENCES skill(id), - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, user_id) ); @@ -55,7 +55,7 @@ CREATE INDEX idx_skill_star_skill_id ON skill_star(skill_id); CREATE TABLE skill_rating ( id BIGSERIAL PRIMARY KEY, skill_id BIGINT NOT NULL REFERENCES skill(id), - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), score SMALLINT NOT NULL CHECK (score >= 1 AND score <= 5), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties new file mode 100644 index 00000000..1e69c99d --- /dev/null +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -0,0 +1,69 @@ +response.success=Success +response.success.read=Fetched successfully +response.success.created=Created successfully +response.success.updated=Updated successfully +response.success.deleted=Deleted successfully +response.success.published=Published successfully +response.success.revoked=Revoked successfully +response.success.health=Service is up + +validation.namespace.slug.notBlank=Slug cannot be blank +validation.namespace.slug.size=Slug must be between 2 and 64 characters +validation.namespace.displayName.notBlank=Display name cannot be blank +validation.namespace.displayName.size=Display name must not exceed 128 characters +validation.namespace.description.size=Description must not exceed 512 characters +validation.member.userId.notNull=User ID is required +validation.member.role.notNull=Role is required +validation.token.name.notBlank=Token name cannot be blank + +error.auth.required=Authentication required +error.badRequest=Invalid request +error.forbidden=Forbidden +error.rateLimit.exceeded=Rate limit exceeded +error.internal=An unexpected error occurred +error.slug.blank=Slug cannot be blank +error.slug.length=Slug length must be between {0} and {1} characters +error.slug.pattern=Slug must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number +error.slug.doubleHyphen=Slug cannot contain consecutive hyphens +error.slug.reserved=Slug ''{0}'' is reserved and cannot be used +error.namespace.slug.exists=Namespace slug ''{0}'' already exists +error.namespace.id.notFound=Namespace not found: {0} +error.namespace.slug.notFound=Namespace not found: {0} +error.namespace.membership.required=Namespace membership required +error.namespace.admin.required=Namespace owner or admin role required +error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly +error.namespace.member.alreadyExists=User is already a namespace member +error.namespace.member.notFound=Member not found +error.namespace.member.owner.remove=Cannot remove namespace owner +error.namespace.member.owner.setDirect=Cannot set OWNER role directly, use ownership transfer instead +error.namespace.owner.current.notFound=Current owner not found +error.namespace.owner.current.invalid=Current user is not the namespace owner +error.namespace.owner.new.notFound=New owner is not a namespace member +error.skill.metadata.content.empty=SKILL.md content cannot be empty +error.skill.metadata.frontmatter.missingStart=Missing frontmatter start marker ''---'' +error.skill.metadata.frontmatter.missingContent=Missing frontmatter content after start marker +error.skill.metadata.frontmatter.missingEnd=Missing frontmatter end marker ''---'' +error.skill.metadata.yaml.notMap=Frontmatter must be a YAML object +error.skill.metadata.yaml.invalid=Invalid YAML in frontmatter: {0} +error.skill.metadata.requiredField.missing=Missing required field: {0} +error.skill.publish.publisher.notMember=Publisher is not a member of namespace: {0} +error.skill.publish.package.invalid=Package validation failed: {0} +error.skill.publish.skillMd.notFound=SKILL.md not found +error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.skill.notFound=Skill not found: {0} +error.skill.access.denied=Access denied to skill: {0} +error.skill.status.notActive=Skill is not active +error.skill.version.exists=Version already exists: {0} +error.skill.version.notFound=Version not found: {0} +error.skill.version.notPublished=Version is not published: {0} +error.skill.version.latest.unavailable=No published version available for skill: {0} +error.skill.version.latest.notFound=Latest published version not found +error.skill.file.notFound=File not found: {0} +error.skill.tag.latest.reserved=Tag name ''latest'' is reserved +error.skill.tag.latest.delete=Tag name ''latest'' is reserved and cannot be deleted +error.skill.tag.notFound=Tag not found: {0} +error.skill.tag.targetVersion.notPublished=Target version must be published +error.skill.tag.version.missing=Tag does not point to a version: {0} +error.skill.tag.version.notFound=Version pointed by tag not found: {0} +error.skill.bundle.notFound=Published bundle not found in storage +error.skill.resolve.versionTag.conflict=Parameters version and tag cannot be used together diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties new file mode 100644 index 00000000..ae396769 --- /dev/null +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -0,0 +1,69 @@ +response.success=成功 +response.success.read=获取成功 +response.success.created=创建成功 +response.success.updated=更新成功 +response.success.deleted=删除成功 +response.success.published=发布成功 +response.success.revoked=撤销成功 +response.success.health=服务正常 + +validation.namespace.slug.notBlank=slug 不能为空 +validation.namespace.slug.size=slug 长度必须在 2 到 64 个字符之间 +validation.namespace.displayName.notBlank=显示名称不能为空 +validation.namespace.displayName.size=显示名称长度不能超过 128 个字符 +validation.namespace.description.size=描述长度不能超过 512 个字符 +validation.member.userId.notNull=用户 ID 不能为空 +validation.member.role.notNull=角色不能为空 +validation.token.name.notBlank=Token 名称不能为空 + +error.auth.required=需要先登录 +error.badRequest=请求参数不合法 +error.forbidden=没有权限执行该操作 +error.rateLimit.exceeded=请求过于频繁,请稍后再试 +error.internal=服务器内部错误 +error.slug.blank=slug 不能为空 +error.slug.length=slug 长度必须在 {0} 到 {1} 个字符之间 +error.slug.pattern=slug 只能包含小写字母、数字和连字符,且必须以字母或数字开头和结尾 +error.slug.doubleHyphen=slug 不能包含连续连字符 +error.slug.reserved=slug ''{0}'' 是保留字,不能使用 +error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 +error.namespace.id.notFound=未找到命名空间:{0} +error.namespace.slug.notFound=未找到命名空间:{0} +error.namespace.membership.required=需要先加入该命名空间 +error.namespace.admin.required=需要命名空间管理员或所有者权限 +error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色 +error.namespace.member.alreadyExists=用户已经是该命名空间成员 +error.namespace.member.notFound=未找到命名空间成员 +error.namespace.member.owner.remove=不能移除命名空间 OWNER +error.namespace.member.owner.setDirect=不能直接设置 OWNER 角色,请使用所有权转移 +error.namespace.owner.current.notFound=未找到当前所有者 +error.namespace.owner.current.invalid=当前用户不是命名空间所有者 +error.namespace.owner.new.notFound=新所有者不是该命名空间成员 +error.skill.metadata.content.empty=SKILL.md 内容不能为空 +error.skill.metadata.frontmatter.missingStart=缺少 frontmatter 起始标记 --- +error.skill.metadata.frontmatter.missingContent=frontmatter 起始标记后缺少内容 +error.skill.metadata.frontmatter.missingEnd=缺少 frontmatter 结束标记 --- +error.skill.metadata.yaml.notMap=frontmatter 必须是 YAML 对象 +error.skill.metadata.yaml.invalid=frontmatter YAML 非法:{0} +error.skill.metadata.requiredField.missing=缺少必填字段:{0} +error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0} +error.skill.publish.package.invalid=技能包校验失败:{0} +error.skill.publish.skillMd.notFound=未找到 SKILL.md +error.skill.publish.precheck.failed=预发布校验失败:{0} +error.skill.notFound=未找到技能:{0} +error.skill.access.denied=没有权限访问技能:{0} +error.skill.status.notActive=技能未处于 ACTIVE 状态 +error.skill.version.exists=版本已存在:{0} +error.skill.version.notFound=未找到版本:{0} +error.skill.version.notPublished=版本未发布:{0} +error.skill.version.latest.unavailable=技能没有可下载的已发布版本:{0} +error.skill.version.latest.notFound=未找到最新已发布版本 +error.skill.file.notFound=未找到文件:{0} +error.skill.tag.latest.reserved=标签 latest 为系统保留标签 +error.skill.tag.latest.delete=标签 latest 为系统保留标签,不能删除 +error.skill.tag.notFound=未找到标签:{0} +error.skill.tag.targetVersion.notPublished=目标版本必须已发布 +error.skill.tag.version.missing=标签未指向具体版本:{0} +error.skill.tag.version.notFound=未找到标签指向的版本:{0} +error.skill.bundle.notFound=对象存储中未找到已发布技能包 +error.skill.resolve.versionTag.conflict=version 和 tag 参数不能同时传入 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index dc296376..c13c75f3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,10 +1,12 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +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.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -13,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -26,6 +29,9 @@ class AuthControllerTest { @Autowired private MockMvc mockMvc; + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) @@ -34,8 +40,10 @@ class AuthControllerTest { @Test void meShouldReturnCurrentPrincipal() throws Exception { + given(namespaceMemberRepository.findByUserId("user-42")).willReturn(List.of()); + PlatformPrincipal principal = new PlatformPrincipal( - 42L, + "user-42", "tester", "tester@example.com", "https://example.com/avatar.png", @@ -51,17 +59,25 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.userId").value(42)) - .andExpect(jsonPath("$.displayName").value("tester")) - .andExpect(jsonPath("$.oauthProvider").value("github")) - .andExpect(jsonPath("$.platformRoles[0]").value("SUPER_ADMIN")); + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isNotEmpty()) + .andExpect(jsonPath("$.data.userId").value("user-42")) + .andExpect(jsonPath("$.data.displayName").value("tester")) + .andExpect(jsonPath("$.data.oauthProvider").value("github")) + .andExpect(jsonPath("$.data.platformRoles[0]").value("SUPER_ADMIN")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); } @Test void providersShouldExposeGithubLoginEntry() throws Exception { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data[0].id").value("github")) - .andExpect(jsonPath("$.data[0].authorizationUrl").value("/oauth2/authorization/github")); + .andExpect(jsonPath("$.data[0].authorizationUrl").value("/oauth2/authorization/github")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java index 0a35ce1b..6e3d27d2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java @@ -1,10 +1,12 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +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.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -13,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -26,6 +29,9 @@ class CliControllerTest { @Autowired private MockMvc mockMvc; + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + @Test void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/cli/whoami")) @@ -34,8 +40,10 @@ class CliControllerTest { @Test void whoamiShouldReturnCurrentPrincipal() throws Exception { + given(namespaceMemberRepository.findByUserId("user-7")).willReturn(List.of()); + PlatformPrincipal principal = new PlatformPrincipal( - 7L, + "user-7", "cli-user", "cli@example.com", "", @@ -51,9 +59,13 @@ class CliControllerTest { mockMvc.perform(get("/api/v1/cli/whoami").with(authentication(auth))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(7)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isNotEmpty()) + .andExpect(jsonPath("$.data.userId").value("user-7")) .andExpect(jsonPath("$.data.displayName").value("cli-user")) .andExpect(jsonPath("$.data.authType").value("api_token")) - .andExpect(jsonPath("$.data.platformRoles[0]").value("SKILL_ADMIN")); + .andExpect(jsonPath("$.data.platformRoles[0]").value("SKILL_ADMIN")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java index 49dd3083..695fa221 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java @@ -23,6 +23,10 @@ class HealthControllerTest { void shouldReturnHealthStatus() throws Exception { mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("UP")); + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isNotEmpty()) + .andExpect(jsonPath("$.data.message").value("UP")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java new file mode 100644 index 00000000..5022d199 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -0,0 +1,122 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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 SkillControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private SkillQueryService skillQueryService; + + @MockBean + private SkillDownloadService skillDownloadService; + + @Test + void getVersionDetailShouldReturnMetadataFields() throws Exception { + when(skillQueryService.getVersionDetail( + eq("team"), + eq("demo"), + eq("1.0.0"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillVersionDetailDTO( + 10L, + "1.0.0", + "PUBLISHED", + "initial", + 2, + 128L, + LocalDateTime.of(2026, 3, 12, 12, 0), + "{\"name\":\"demo\"}", + "[{\"path\":\"SKILL.md\"}]" + )); + + mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isNotEmpty()) + .andExpect(jsonPath("$.data.version").value("1.0.0")) + .andExpect(jsonPath("$.data.parsedMetadataJson").value("{\"name\":\"demo\"}")) + .andExpect(jsonPath("$.data.manifestJson").value("[{\"path\":\"SKILL.md\"}]")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } + + @Test + void resolveVersionShouldReturnUnifiedEnvelope() throws Exception { + when(skillQueryService.resolveVersion( + eq("team"), + eq("demo"), + eq(null), + eq("latest"), + eq(null), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "team", + "demo", + "1.2.0", + 20L, + "sha256:abc", + null, + "/api/v1/skills/team/demo/versions/1.2.0/download" + )); + + mockMvc.perform(get("/api/v1/skills/team/demo/resolve").param("tag", "latest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.namespace").value("team")) + .andExpect(jsonPath("$.data.slug").value("demo")) + .andExpect(jsonPath("$.data.downloadUrl").value("/api/v1/skills/team/demo/versions/1.2.0/download")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } + + @Test + void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { + when(skillQueryService.listFilesByTag( + eq("team"), + eq("demo"), + eq("latest"), + eq((String) null), + eq(Map.of()))) + .thenReturn(List.of(new SkillFile(20L, "README.md", 32L, "text/markdown", "hash", "key"))); + + mockMvc.perform(get("/api/v1/skills/team/demo/tags/latest/files")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].filePath").value("README.md")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java new file mode 100644 index 00000000..d0252b70 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.service.SkillSearchAppService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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 SkillSearchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private SkillSearchAppService skillSearchAppService; + + @Test + void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception { + when(skillSearchAppService.search( + eq("review"), + eq("global"), + eq("newest"), + eq(0), + eq(20), + eq((String) null), + eq(null))) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/v1/skills") + .param("q", "review") + .param("namespace", "global")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.total").value(0)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillRatingEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillRatingEventListenerTest.java new file mode 100644 index 00000000..01822f5d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillRatingEventListenerTest.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.social.SkillRatingRepository; +import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillRatingEventListenerTest { + @Mock JdbcTemplate jdbcTemplate; + @Mock SkillRatingRepository ratingRepository; + @InjectMocks SkillRatingEventListener listener; + + @Test + void onRated_updates_rating_avg_and_count() { + when(ratingRepository.averageScoreBySkillId(1L)).thenReturn(4.2); + when(ratingRepository.countBySkillId(1L)).thenReturn(10); + listener.onRated(new SkillRatedEvent(1L, "10", (short) 5)); + verify(jdbcTemplate).update( + "UPDATE skill SET rating_avg = ?, rating_count = ? WHERE id = ?", + 4.2, 10, 1L); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillStarEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillStarEventListenerTest.java new file mode 100644 index 00000000..45cb9777 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillStarEventListenerTest.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.social.SkillStarRepository; +import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; +import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillStarEventListenerTest { + @Mock JdbcTemplate jdbcTemplate; + @Mock SkillStarRepository starRepository; + @InjectMocks SkillStarEventListener listener; + + @Test + void onStarred_updates_star_count() { + when(starRepository.countBySkillId(1L)).thenReturn(42L); + listener.onStarred(new SkillStarredEvent(1L, "10")); + verify(jdbcTemplate).update("UPDATE skill SET star_count = ? WHERE id = ?", 42, 1L); + } + + @Test + void onUnstarred_updates_star_count() { + when(starRepository.countBySkillId(1L)).thenReturn(41L); + listener.onUnstarred(new SkillUnstarredEvent(1L, "10")); + verify(jdbcTemplate).update("UPDATE skill SET star_count = ? WHERE id = ?", 41, 1L); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index afe91d32..5254e8c1 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -16,7 +16,6 @@ spring: port: 6379 autoconfigure: exclude: - - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration security: oauth2: