mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
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
This commit is contained in:
parent
6df55e3768
commit
b520689130
64 changed files with 1620 additions and 289 deletions
|
|
@ -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<Map<String, Object>> me(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
Authentication authentication) {
|
||||
public ApiResponse<AuthMeResponse> 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<Map<String, Object>> providers() {
|
||||
var github = Map.of(
|
||||
"id", "github",
|
||||
"name", "GitHub",
|
||||
"authorizationUrl", "/oauth2/authorization/github"
|
||||
);
|
||||
return ResponseEntity.ok(Map.of("data", List.of(github)));
|
||||
public ApiResponse<List<AuthProviderResponse>> providers() {
|
||||
var github = new AuthProviderResponse("github", "GitHub", "/oauth2/authorization/github");
|
||||
return ok("response.success.read", List.of(github));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <T> ApiResponse<T> ok(String messageCode, T data, Object... args) {
|
||||
return responseFactory.ok(messageCode, data, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Map<String, Object>> whoami(@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
public ApiResponse<CliWhoamiResponse> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> health() {
|
||||
return Map.of("status", "UP");
|
||||
public ApiResponse<MessageResponse> health() {
|
||||
return ok("response.success.health", new MessageResponse("UP"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> create(
|
||||
public ApiResponse<TokenCreateResponse> create(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@RequestBody Map<String, Object> 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<TokenSummaryResponse>> 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<Void> revoke(
|
||||
public ApiResponse<MessageResponse> 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PublishResponse> publish(
|
||||
public ApiResponse<PublishResponse> 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<PackageEntry> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
|
||||
|
|
|
|||
|
|
@ -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<List<SkillSummaryResponse>> listMySkills(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
|
||||
return ok("response.success.read", mySkillAppService.listMySkills(principal.userId()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object> listNamespaces(Pageable pageable) {
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
|
||||
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
|
||||
Page<NamespaceResponse> response = namespaces.map(NamespaceResponse::from);
|
||||
return Map.of("code", 0, "data", response);
|
||||
PageResponse<NamespaceResponse> response = PageResponse.from(namespaces.map(NamespaceResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
@GetMapping("/{slug}")
|
||||
public Map<String, Object> getNamespace(@PathVariable String slug) {
|
||||
public ApiResponse<NamespaceResponse> 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<String, Object> createNamespace(
|
||||
public ApiResponse<NamespaceResponse> 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<String, Object> updateNamespace(
|
||||
public ApiResponse<NamespaceResponse> 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<String, Object> listMembers(@PathVariable String slug, Pageable pageable) {
|
||||
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug, Pageable pageable) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
|
||||
Page<MemberResponse> response = members.map(MemberResponse::from);
|
||||
return Map.of("code", 0, "data", response);
|
||||
PageResponse<MemberResponse> response = PageResponse.from(members.map(MemberResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/members")
|
||||
public Map<String, Object> addMember(
|
||||
public ApiResponse<MemberResponse> 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<String, Object> removeMember(
|
||||
public ApiResponse<MessageResponse> 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<String, Object> updateMemberRole(
|
||||
public ApiResponse<MemberResponse> updateMemberRole(
|
||||
@PathVariable String slug,
|
||||
@PathVariable Long userId,
|
||||
@RequestBody Map<String, NamespaceRole> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ public class PromotionController extends BaseApiController {
|
|||
@PostMapping
|
||||
public ApiResponse<PromotionResponseDto> 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<PromotionResponseDto> 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<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles);
|
||||
|
|
@ -76,7 +76,7 @@ public class PromotionController extends BaseApiController {
|
|||
public ApiResponse<PromotionResponseDto> 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<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles);
|
||||
|
|
@ -87,7 +87,7 @@ public class PromotionController extends BaseApiController {
|
|||
public ApiResponse<PageResponse<PromotionResponseDto>> listPendingPromotions(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") Long userId) {
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
|
||||
if (!hasAdminRole) {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public class ReviewController extends BaseApiController {
|
|||
@PostMapping
|
||||
public ApiResponse<ReviewTaskResponse> 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<ReviewTaskResponse> approveReview(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewActionRequest request,
|
||||
@RequestAttribute("userId") Long userId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
|
|
@ -81,7 +81,7 @@ public class ReviewController extends BaseApiController {
|
|||
public ApiResponse<ReviewTaskResponse> rejectReview(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewActionRequest request,
|
||||
@RequestAttribute("userId") Long userId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
|
|
@ -93,7 +93,7 @@ public class ReviewController extends BaseApiController {
|
|||
@PostMapping("/{id}/withdraw")
|
||||
public ApiResponse<Void> 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<ReviewTask> 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<PageResponse<ReviewTaskResponse>> listMySubmissions(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") Long userId) {
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Page<ReviewTask> tasks = reviewTaskRepository.findBySubmittedByAndStatus(
|
||||
userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
|
||||
return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse)));
|
||||
|
|
|
|||
|
|
@ -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<SkillDetailResponse> getSkillDetail(
|
||||
public ApiResponse<SkillDetailResponse> 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<Long, NamespaceRole> 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<Page<SkillVersionResponse>> listVersions(
|
||||
public ApiResponse<PageResponse<SkillVersionResponse>> listVersions(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
|
|
@ -76,7 +80,7 @@ public class SkillController {
|
|||
Page<SkillVersion> versions = skillQueryService.listVersions(
|
||||
namespace, slug, PageRequest.of(page, size));
|
||||
|
||||
Page<SkillVersionResponse> response = versions.map(v -> new SkillVersionResponse(
|
||||
PageResponse<SkillVersionResponse> 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<SkillVersionDetailResponse> getVersionDetail(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String version,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<List<SkillFileResponse>> listFiles(
|
||||
public ApiResponse<List<SkillFileResponse>> 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<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
List<SkillFile> files = skillQueryService.listFiles(namespace, slug, version);
|
||||
List<SkillFile> files = skillQueryService.listFiles(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
|
||||
List<SkillFileResponse> 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<List<SkillFileResponse>> listFilesByTag(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String tagName,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
List<SkillFile> files = skillQueryService.listFilesByTag(
|
||||
namespace,
|
||||
slug,
|
||||
tagName,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
|
||||
List<SkillFileResponse> 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<Long, NamespaceRole> 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<InputStreamResource> 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<Long, NamespaceRole> 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<ResolveVersionResponse> 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<Long, NamespaceRole> 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<InputStreamResource> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<InputStreamResource> downloadByTag(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String tagName,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PublishResponse> publish(
|
||||
public ApiResponse<PublishResponse> 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<PackageEntry> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
|
||||
|
|
|
|||
|
|
@ -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<SkillSearchAppService.SearchResponse> search(
|
||||
public ApiResponse<SkillSearchAppService.SearchResponse> 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<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
|
|
@ -39,6 +43,6 @@ public class SkillSearchController {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<TagResponse>> listTags(
|
||||
public ApiResponse<List<TagResponse>> 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<TagResponse> createOrMoveTag(
|
||||
public ApiResponse<TagResponse> 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<Void> deleteTag(
|
||||
public ApiResponse<MessageResponse> 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ApiResponse<T>(
|
||||
int code,
|
||||
String msg,
|
||||
T data,
|
||||
Instant timestamp,
|
||||
String requestId
|
||||
) {}
|
||||
|
|
@ -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 <T> ApiResponse<T> 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<Void> 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<Void> errorMessage(int code, String msg) {
|
||||
return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AuthProviderResponse(
|
||||
String id,
|
||||
String name,
|
||||
String authorizationUrl
|
||||
) {}
|
||||
|
|
@ -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<String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record MessageResponse(
|
||||
String message
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ public record NamespaceResponse(
|
|||
String description,
|
||||
NamespaceType type,
|
||||
String avatarUrl,
|
||||
Long createdBy,
|
||||
String createdBy,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
long total,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
public static <T> PageResponse<T> from(Page<T> page) {
|
||||
return new PageResponse<>(
|
||||
page.getContent(),
|
||||
page.getTotalElements(),
|
||||
page.getNumber(),
|
||||
page.getSize()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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<String> scopes
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record TokenCreateResponse(
|
||||
String token,
|
||||
Long id,
|
||||
String name,
|
||||
String tokenPrefix,
|
||||
String createdAt,
|
||||
String expiresAt
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record TokenSummaryResponse(
|
||||
Long id,
|
||||
String name,
|
||||
String tokenPrefix,
|
||||
String createdAt,
|
||||
String expiresAt,
|
||||
String lastUsedAt
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainForbiddenException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainForbidden(DomainForbiddenException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
|
||||
apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> 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<ErrorResponse> handleBadRequest(IllegalArgumentException ex,
|
||||
HttpServletRequest request) {
|
||||
String requestId = MDC.get("requestId");
|
||||
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleForbidden(SecurityException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
|
||||
apiResponseFactory.error(403, "error.forbidden"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGlobalException(Exception ex,
|
||||
HttpServletRequest request) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
public interface LocalizedError {
|
||||
String messageCode();
|
||||
|
||||
Object[] messageArgs();
|
||||
|
||||
HttpStatus status();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Long, NamespaceRole> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Deque<Long>> requests = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public boolean tryAcquire(String key, int limit, int windowSeconds) {
|
||||
long now = System.currentTimeMillis();
|
||||
long windowMillis = windowSeconds * 1000L;
|
||||
Deque<Long> 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<Long> timestamps, long threshold) {
|
||||
while (!timestamps.isEmpty()) {
|
||||
Long oldest = timestamps.peekFirst();
|
||||
if (oldest == null || oldest >= threshold) {
|
||||
return;
|
||||
}
|
||||
timestamps.pollFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void> body = apiResponseFactory.error(429, "error.rateLimit.exceeded");
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.ratelimit;
|
||||
|
||||
public interface RateLimiter {
|
||||
|
||||
boolean tryAcquire(String key, int limit, int windowSeconds);
|
||||
}
|
||||
|
|
@ -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<Long> 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;
|
||||
|
|
@ -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<Void> body = apiResponseFactory.error(403, "error.forbidden");
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void> body = apiResponseFactory.error(401, "error.auth.required");
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillSummaryResponse> listMySkills(String userId) {
|
||||
List<Skill> skills = skillRepository.findByOwnerId(userId).stream()
|
||||
.sorted(Comparator.comparing(Skill::getUpdatedAt).reversed())
|
||||
.toList();
|
||||
|
||||
List<Long> latestVersionIds = skills.stream()
|
||||
.map(Skill::getLatestVersionId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
|
||||
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
|
||||
|
||||
List<Long> namespaceIds = skills.stream()
|
||||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getId,
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
|
||||
|
||||
return skills.stream()
|
||||
.map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(
|
||||
Skill skill,
|
||||
Map<Long, SkillVersion> versionsById,
|
||||
Map<Long, String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillSummaryResponse> skills,
|
||||
List<SkillSummaryResponse> 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<Long, NamespaceRole> 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<Skill> matchedSkills = result.skillIds().isEmpty()
|
||||
? List.of()
|
||||
: skillRepository.findByIdIn(result.skillIds());
|
||||
Map<Long, Skill> skillsById = matchedSkills.stream()
|
||||
.collect(Collectors.toMap(Skill::getId, Function.identity()));
|
||||
|
||||
List<SkillSummaryResponse> skills = new ArrayList<>();
|
||||
for (Long skillId : result.skillIds()) {
|
||||
skillRepository.findById(skillId).ifPresent(skill -> {
|
||||
skills.add(toSummaryResponse(skill));
|
||||
});
|
||||
}
|
||||
List<Long> latestVersionIds = matchedSkills.stream()
|
||||
.map(Skill::getLatestVersionId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
|
||||
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
|
||||
|
||||
List<Long> namespaceIds = matchedSkills.stream()
|
||||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId,
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
|
||||
|
||||
List<SkillSummaryResponse> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, SkillVersion> versionsById,
|
||||
Map<Long, String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ server:
|
|||
shutdown: graceful
|
||||
|
||||
spring:
|
||||
messages:
|
||||
basename: messages
|
||||
application:
|
||||
name: skillhub
|
||||
lifecycle:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
69
server/skillhub-app/src/main/resources/messages.properties
Normal file
69
server/skillhub-app/src/main/resources/messages.properties
Normal file
|
|
@ -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
|
||||
|
|
@ -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 参数不能同时传入
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.<Long, NamespaceRole>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.<Long, NamespaceRole>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.<Long, NamespaceRole>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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ spring:
|
|||
port: 6379
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.session.SessionAutoConfiguration
|
||||
security:
|
||||
oauth2:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue