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:
vsxd 2026-03-12 17:47:58 +08:00
parent 6df55e3768
commit b520689130
64 changed files with 1620 additions and 289 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
public record AuthProviderResponse(
String id,
String name,
String authorizationUrl
) {}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
public record MessageResponse(
String message
) {}

View file

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

View file

@ -14,7 +14,7 @@ public record NamespaceResponse(
String description,
NamespaceType type,
String avatarUrl,
Long createdBy,
String createdBy,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
public record TokenCreateResponse(
String token,
Long id,
String name,
String tokenPrefix,
String createdAt,
String expiresAt
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
public record TokenSummaryResponse(
Long id,
String name,
String tokenPrefix,
String createdAt,
String expiresAt,
String lastUsedAt
) {}

View file

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

View file

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

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.exception;
import org.springframework.http.HttpStatus;
public interface LocalizedError {
String messageCode();
Object[] messageArgs();
HttpStatus status();
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.ratelimit;
public interface RateLimiter {
boolean tryAcquire(String key, int limit, int windowSeconds);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@ server:
shutdown: graceful
spring:
messages:
basename: messages
application:
name: skillhub
lifecycle:

View file

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

View file

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

View file

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

View 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

View file

@ -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 参数不能同时传入

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,6 @@ spring:
port: 6379
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
- org.springframework.boot.autoconfigure.session.SessionAutoConfiguration
security:
oauth2: