Merge remote-tracking branch 'origin/main' into feature/project-fixbug

# Conflicts:
#	web/src/api/client.ts
#	web/src/app/router.tsx
#	web/src/pages/skill-detail.tsx
This commit is contained in:
yun-zhi-ztl 2026-03-17 10:55:11 +08:00
commit c9e7e1ce28
52 changed files with 1200 additions and 399 deletions

View file

@ -23,7 +23,7 @@ services:
REDIS_PORT: 6379
SESSION_COOKIE_SECURE: "false"
SKILLHUB_PUBLIC_BASE_URL: "http://localhost"
DEVICE_AUTH_VERIFICATION_URI: "http://localhost/api/device/activate"
DEVICE_AUTH_VERIFICATION_URI: "http://localhost/cli/auth"
SKILLHUB_STORAGE_PROVIDER: s3
STORAGE_BASE_PATH: /var/lib/skillhub/storage
SKILLHUB_STORAGE_S3_ENDPOINT: http://minio:9000

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.compat.dto.ClawHubDeleteResponse;
import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse;
import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
import com.iflytek.skillhub.compat.dto.ClawHubStarResponse;
import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse;
@ -23,8 +24,10 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.SkillSearchAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
@ -55,6 +58,7 @@ public class ClawHubCompatController {
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillStarService skillStarService;
private final SkillSlugResolutionService skillSlugResolutionService;
public ClawHubCompatController(CanonicalSlugMapper mapper,
SkillSearchAppService skillSearchAppService,
@ -66,7 +70,8 @@ public class ClawHubCompatController {
SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
SkillStarService skillStarService) {
SkillStarService skillStarService,
SkillSlugResolutionService skillSlugResolutionService) {
this.mapper = mapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
@ -78,8 +83,10 @@ public class ClawHubCompatController {
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillStarService = skillStarService;
this.skillSlugResolutionService = skillSlugResolutionService;
}
@RateLimit(category = "search", authenticated = 60, anonymous = 20)
@GetMapping("/search")
public ClawHubSearchResponse search(
@RequestParam String q,
@ -125,6 +132,7 @@ public class ClawHubCompatController {
return (starScore + downloadScore) / 100.0;
}
@RateLimit(category = "resolve", authenticated = 60, anonymous = 20)
@GetMapping("/resolve")
public ClawHubResolveResponse resolveByQuery(
@RequestParam String slug,
@ -160,6 +168,7 @@ public class ClawHubCompatController {
return new ClawHubResolveResponse(matchVersion, latestVersion);
}
@RateLimit(category = "resolve", authenticated = 60, anonymous = 20)
@GetMapping("/resolve/{canonicalSlug}")
public ClawHubResolveResponse resolve(
@PathVariable String canonicalSlug,
@ -187,6 +196,7 @@ public class ClawHubCompatController {
return new ClawHubResolveResponse(matchVersion, latestVersion);
}
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
@GetMapping("/download/{canonicalSlug}")
public ResponseEntity<Void> downloadByPath(@PathVariable String canonicalSlug,
@RequestParam(defaultValue = "latest") String version) {
@ -199,6 +209,7 @@ public class ClawHubCompatController {
.build();
}
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
@GetMapping("/download")
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
@RequestParam(defaultValue = "latest") String version) {
@ -215,8 +226,9 @@ public class ClawHubCompatController {
.build();
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@GetMapping("/skills")
public ClawHubSearchResponse listSkills(
public ClawHubSkillListResponse listSkills(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int limit,
@RequestParam(required = false) String sort,
@ -234,13 +246,59 @@ public class ClawHubCompatController {
userNsRoles
);
List<ClawHubSearchResponse.ClawHubSearchResult> results = response.items().stream()
.map(this::toSearchResult)
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
.map(this::toSkillListItem)
.toList();
return new ClawHubSearchResponse(results);
// Calculate nextCursor: if there are more results, return next page number as cursor
String nextCursor = null;
long totalResults = response.total();
long currentOffset = (long) page * limit;
if (currentOffset + items.size() < totalResults) {
nextCursor = String.valueOf(page + 1);
}
return new ClawHubSkillListResponse(items, nextCursor);
}
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
long createdAt = 0;
long updatedAt = item.updatedAt() != null
? item.updatedAt().toInstant(ZoneOffset.UTC).toEpochMilli()
: 0;
ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null;
if (item.latestVersion() != null) {
latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion(
item.latestVersion(),
updatedAt, // Use skill's updatedAt as version createdAt
"", // changelog not available in summary
null // license not available in summary
);
}
// Build stats map with non-null values
Map<String, Object> stats = new java.util.HashMap<>();
if (item.downloadCount() != null) {
stats.put("downloads", item.downloadCount());
}
if (item.starCount() != null) {
stats.put("stars", item.starCount());
}
return new ClawHubSkillListResponse.SkillListItem(
mapper.toCanonical(item.namespace(), item.slug()),
item.displayName(),
item.summary(),
Map.of(), // tags
stats,
createdAt,
updatedAt,
latestVersion
);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@GetMapping("/skills/{canonicalSlug}")
public ClawHubSkillResponse getSkill(
@PathVariable String canonicalSlug,
@ -250,8 +308,7 @@ public class ClawHubCompatController {
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", coord.slug()));
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), userId);
SkillVersion latestVersionEntity = null;
if (skill.getLatestVersionId() != null) {
@ -302,6 +359,7 @@ public class ClawHubCompatController {
return new ClawHubSkillResponse(skillInfo, versionInfo, ownerInfo, moderationInfo);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@DeleteMapping("/skills/{canonicalSlug}")
public ClawHubDeleteResponse deleteSkill(
@PathVariable String canonicalSlug,
@ -310,6 +368,7 @@ public class ClawHubCompatController {
return new ClawHubDeleteResponse();
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@PostMapping("/skills/{canonicalSlug}/undelete")
public ClawHubDeleteResponse undeleteSkill(
@PathVariable String canonicalSlug,
@ -318,6 +377,7 @@ public class ClawHubCompatController {
return new ClawHubDeleteResponse();
}
@RateLimit(category = "stars", authenticated = 60, anonymous = 20)
@PostMapping("/stars/{canonicalSlug}")
public ClawHubStarResponse starSkill(
@PathVariable String canonicalSlug,
@ -325,8 +385,7 @@ public class ClawHubCompatController {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug));
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId());
boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId());
skillStarService.star(skill.getId(), principal.userId());
@ -334,6 +393,7 @@ public class ClawHubCompatController {
return new ClawHubStarResponse(true, alreadyStarred);
}
@RateLimit(category = "stars", authenticated = 60, anonymous = 20)
@DeleteMapping("/stars/{canonicalSlug}")
public ClawHubUnstarResponse unstarSkill(
@PathVariable String canonicalSlug,
@ -341,8 +401,7 @@ public class ClawHubCompatController {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug));
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId());
boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId());
skillStarService.unstar(skill.getId(), principal.userId());
@ -350,6 +409,7 @@ public class ClawHubCompatController {
return new ClawHubUnstarResponse(true, alreadyUnstarred);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@PostMapping("/skills")
public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson,
@RequestParam("files") MultipartFile[] files,
@ -380,6 +440,7 @@ public class ClawHubCompatController {
);
}
@RateLimit(category = "publish", authenticated = 60, anonymous = 20)
@PostMapping("/publish")
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
@RequestParam("namespace") String namespace,
@ -413,6 +474,7 @@ public class ClawHubCompatController {
return "global";
}
@RateLimit(category = "whoami", authenticated = 60, anonymous = 20)
@GetMapping("/whoami")
public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) {
return new ClawHubWhoamiResponse(
@ -421,4 +483,16 @@ public class ClawHubCompatController {
principal.avatarUrl()
);
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
try {
return skillSlugResolutionService.resolve(
namespaceId,
slug,
currentUserId,
SkillSlugResolutionService.Preference.PUBLISHED);
} catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException ex) {
throw new DomainNotFoundException("error.skill.notFound", slug);
}
}
}

View file

@ -1,6 +1,13 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuthMeResponse;
@ -28,6 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController extends BaseApiController {
@ -36,25 +46,50 @@ public class AuthController extends BaseApiController {
private final SessionBootstrapService sessionBootstrapService;
private final DirectAuthService directAuthService;
private final AuthFailureThrottleService authFailureThrottleService;
private final UserRoleBindingRepository userRoleBindingRepository;
private final PlatformSessionService platformSessionService;
private final UserAccountRepository userAccountRepository;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
SessionBootstrapService sessionBootstrapService,
DirectAuthService directAuthService,
AuthFailureThrottleService authFailureThrottleService) {
AuthFailureThrottleService authFailureThrottleService,
UserRoleBindingRepository userRoleBindingRepository,
PlatformSessionService platformSessionService,
UserAccountRepository userAccountRepository) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
this.directAuthService = directAuthService;
this.authFailureThrottleService = authFailureThrottleService;
this.userRoleBindingRepository = userRoleBindingRepository;
this.platformSessionService = platformSessionService;
this.userAccountRepository = userAccountRepository;
}
@GetMapping("/me")
public ApiResponse<AuthMeResponse> me(@AuthenticationPrincipal PlatformPrincipal principal,
Authentication authentication) {
Authentication authentication,
HttpServletRequest request) {
if (principal == null || authentication == null || !authentication.isAuthenticated()) {
throw new UnauthorizedException("error.auth.required");
}
UserAccount user = userAccountRepository.findById(principal.userId()).orElse(null);
if (user == null || user.getStatus() == UserStatus.DISABLED) {
request.getSession().invalidate();
throw new UnauthorizedException("error.auth.required");
}
Set<String> freshRoles = PlatformRoleDefaults.withDefaultUserRole(
userRoleBindingRepository.findByUserId(principal.userId()).stream()
.map(binding -> binding.getRole().getCode())
.collect(Collectors.toSet()));
if (!freshRoles.equals(principal.platformRoles())) {
principal = new PlatformPrincipal(
principal.userId(), principal.displayName(), principal.email(),
principal.avatarUrl(), principal.oauthProvider(), freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
}

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.dto.ApiResponse;
@ -21,21 +23,30 @@ import java.util.List;
public class TokenController extends BaseApiController {
private final ApiTokenService apiTokenService;
private final ObjectMapper objectMapper;
public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory) {
public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory, ObjectMapper objectMapper) {
super(responseFactory);
this.apiTokenService = apiTokenService;
this.objectMapper = objectMapper;
}
@PostMapping
public ApiResponse<TokenCreateResponse> create(
@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody TokenCreateRequest request) {
String scopeJson = request.scopes() == null || request.scopes().isEmpty()
? "[\"skill:read\",\"skill:publish\"]"
: request.scopes().toString();
String scopeJson;
if (request.scopes() == null || request.scopes().isEmpty()) {
scopeJson = "[\"skill:read\",\"skill:publish\"]";
} else {
try {
scopeJson = objectMapper.writeValueAsString(request.scopes());
} catch (JsonProcessingException e) {
scopeJson = "[\"skill:read\",\"skill:publish\"]";
}
}
var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson, request.expiresAt());
var result = apiTokenService.rotateToken(principal.userId(), request.name(), scopeJson, request.expiresAt());
return ok("response.success.created", new TokenCreateResponse(
result.rawToken(),
result.entity().getId(),

View file

@ -8,11 +8,11 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
@ -34,29 +34,29 @@ import org.springframework.web.bind.annotation.RestController;
public class SkillLifecycleController extends BaseApiController {
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillGovernanceService skillGovernanceService;
private final ReviewService reviewService;
private final SkillPublishService skillPublishService;
private final AuditLogService auditLogService;
private final SkillSlugResolutionService skillSlugResolutionService;
public SkillLifecycleController(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillGovernanceService skillGovernanceService,
ReviewService reviewService,
SkillPublishService skillPublishService,
AuditLogService auditLogService,
SkillSlugResolutionService skillSlugResolutionService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillGovernanceService = skillGovernanceService;
this.reviewService = reviewService;
this.skillPublishService = skillPublishService;
this.auditLogService = auditLogService;
this.skillSlugResolutionService = skillSlugResolutionService;
}
@PostMapping("/{namespace}/{slug}/archive")
@ -66,7 +66,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
Skill archived = skillGovernanceService.archiveSkill(
skill.getId(),
userId,
@ -86,7 +86,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
Skill restored = skillGovernanceService.unarchiveSkill(
skill.getId(),
userId,
@ -106,7 +106,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
skillGovernanceService.deleteVersion(
@ -128,7 +128,7 @@ public class SkillLifecycleController extends BaseApiController {
@PathVariable String version,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
reviewService.withdrawReview(skillVersion.getId(), userId);
@ -155,7 +155,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion(
@ -181,11 +181,18 @@ public class SkillLifecycleController extends BaseApiController {
new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name()));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,
slug,
currentUserId,
SkillSlugResolutionService.Preference.CURRENT_USER);
}
}

View file

@ -6,7 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.report.SkillReportService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillReportMutationResponse;
@ -24,17 +24,17 @@ import org.springframework.web.bind.annotation.RestController;
public class SkillReportController extends BaseApiController {
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillReportService skillReportService;
private final SkillSlugResolutionService skillSlugResolutionService;
public SkillReportController(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillReportService skillReportService,
SkillSlugResolutionService skillSlugResolutionService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillReportService = skillReportService;
this.skillSlugResolutionService = skillSlugResolutionService;
}
@PostMapping("/{namespace}/{slug}/reports")
@ -43,7 +43,7 @@ public class SkillReportController extends BaseApiController {
@RequestBody SkillReportSubmitRequest request,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
var report = skillReportService.submitReport(
skill.getId(),
userId,
@ -55,11 +55,14 @@ public class SkillReportController extends BaseApiController {
return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
return skillSlugResolutionService.resolve(
namespace.getId(),
skillSlug,
currentUserId,
SkillSlugResolutionService.Preference.PUBLISHED);
}
}

View file

@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
@RequestMapping({"/api/web/skills"})
public class SkillSearchController extends BaseApiController {
private final SkillSearchAppService skillSearchAppService;
@ -22,7 +22,6 @@ public class SkillSearchController extends BaseApiController {
this.skillSearchAppService = skillSearchAppService;
}
@GetMapping
@RateLimit(category = "search", authenticated = 60, anonymous = 20)
public ApiResponse<SkillSearchAppService.SearchResponse> search(
@RequestParam(required = false) String q,

View file

@ -4,11 +4,11 @@ server:
forward-headers-strategy: framework
servlet:
session:
timeout: ${SERVER_SERVLET_SESSION_TIMEOUT:8h}
cookie:
http-only: true
secure: ${SESSION_COOKIE_SECURE:false}
same-site: lax
max-age: 28800
spring:
messages:
@ -98,7 +98,7 @@ skillhub:
max-package-size: 104857600 # 100MB
allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg
device-auth:
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
bootstrap:
admin:
enabled: ${BOOTSTRAP_ADMIN_ENABLED:false}

View file

@ -0,0 +1,6 @@
-- V12__skill_owner_uniqueness.sql
-- Change skill uniqueness from (namespace_id, slug) to (namespace_id, slug, owner_id)
-- to support owner-isolated skill records with the same name
ALTER TABLE skill DROP CONSTRAINT skill_namespace_id_slug_key;
ALTER TABLE skill ADD CONSTRAINT skill_namespace_id_slug_owner_id_key UNIQUE(namespace_id, slug, owner_id);

View file

@ -119,3 +119,5 @@ error.admin.user.role.invalid=Invalid role: {0}
error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role
error.admin.user.status.invalid=Invalid user status: {0}
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace

View file

@ -119,3 +119,5 @@ error.admin.user.role.invalid=无效的角色:{0}
error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色
error.admin.user.status.invalid=无效的用户状态:{0}
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能

View file

@ -1,89 +0,0 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.device.DeviceCodeResponse;
import com.iflytek.skillhub.auth.device.DeviceTokenResponse;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class DeviceAuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void requestDeviceCode_returns_code() throws Exception {
DeviceCodeResponse response = new DeviceCodeResponse(
"device_abc123",
"ABCD-1234",
"https://skillhub.example.com/device",
900,
5
);
given(deviceAuthService.generateDeviceCode()).willReturn(response);
mockMvc.perform(post("/api/v1/auth/device/code")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.deviceCode").value("device_abc123"))
.andExpect(jsonPath("$.data.userCode").value("ABCD-1234"))
.andExpect(jsonPath("$.data.verificationUri").value("https://skillhub.example.com/device"))
.andExpect(jsonPath("$.data.expiresIn").value(900))
.andExpect(jsonPath("$.data.interval").value(5));
}
@Test
void pollToken_returns_pending() throws Exception {
DeviceTokenResponse response = DeviceTokenResponse.pending();
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
mockMvc.perform(post("/api/v1/auth/device/token")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"deviceCode\": \"device_abc123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.error").value("authorization_pending"))
.andExpect(jsonPath("$.data.accessToken").isEmpty())
.andExpect(jsonPath("$.data.tokenType").isEmpty());
}
@Test
void pollToken_returns_access_token_when_authorized() throws Exception {
DeviceTokenResponse response = DeviceTokenResponse.success("sk_device_flow_token");
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
mockMvc.perform(post("/api/v1/auth/device/token")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"deviceCode\": \"device_abc123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").value("sk_device_flow_token"))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.error").isEmpty());
}
}

View file

@ -81,7 +81,7 @@ class SkillLifecycleControllerTest {
setSkillId(skill, 1L);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup")))
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED));
@ -108,7 +108,7 @@ class SkillLifecycleControllerTest {
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class)))
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE));
@ -135,7 +135,7 @@ class SkillLifecycleControllerTest {
version.setStatus(SkillVersionStatus.DRAFT);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0")
@ -162,7 +162,7 @@ class SkillLifecycleControllerTest {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review")
@ -188,7 +188,7 @@ class SkillLifecycleControllerTest {
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
setSkillVersionId(sourceVersion, 2L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);

View file

@ -64,7 +64,7 @@ class SkillReportControllerTest {
ReflectionTestUtils.setField(report, "id", 99L);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class)))
.willReturn(report);

View file

@ -32,7 +32,7 @@ public class DeviceAuthService {
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService,
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
this.redisTemplate = redisTemplate;
this.apiTokenService = apiTokenService;
this.verificationUri = verificationUri;
@ -109,7 +109,7 @@ public class DeviceAuthService {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
}
String token = apiTokenService.createToken(
String token = apiTokenService.rotateToken(
data.getUserId(),
CLI_DEVICE_TOKEN_NAME,
CLI_DEVICE_SCOPE_JSON

View file

@ -15,4 +15,5 @@ public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
Page<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable);
boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name);
Optional<ApiToken> findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(String userId, String name);
}

View file

@ -63,6 +63,26 @@ public class ApiTokenService {
return new TokenCreateResult(rawToken, token);
}
/**
* Revoke existing token with the same name (if any) and create a new one.
* Used by device auth flow to avoid duplicate-name errors on repeated logins.
*/
@Transactional
public TokenCreateResult rotateToken(String userId, String name, String scopeJson) {
return rotateToken(userId, name, scopeJson, null);
}
@Transactional
public TokenCreateResult rotateToken(String userId, String name, String scopeJson, String expiresAt) {
String normalizedName = normalizeName(name);
tokenRepo.findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(userId, normalizedName)
.ifPresent(existing -> {
existing.setRevokedAt(LocalDateTime.now());
tokenRepo.save(existing);
});
return createToken(userId, name, scopeJson, expiresAt);
}
public Optional<ApiToken> validateToken(String rawToken) {
String hash = sha256(rawToken);
return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid);

View file

@ -1,159 +0,0 @@
package com.iflytek.skillhub.auth.device;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DeviceAuthServiceTest {
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private ApiTokenService apiTokenService;
private DeviceAuthService service;
@BeforeEach
void setUp() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device");
}
@Test
void generateDeviceCode_returns_valid_response() {
// When
DeviceCodeResponse response = service.generateDeviceCode();
// Then
assertThat(response.deviceCode()).isNotEmpty();
assertThat(response.userCode()).matches("[A-Z2-9]{4}-[A-Z2-9]{4}");
assertThat(response.verificationUri()).isEqualTo("https://skillhub.example.com/device");
assertThat(response.expiresIn()).isEqualTo(900); // 15 minutes
assertThat(response.interval()).isEqualTo(5);
// Verify Redis storage
ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object> valueCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations, times(2)).set(keyCaptor.capture(), valueCaptor.capture(), eq(15L), eq(TimeUnit.MINUTES));
// Verify device code key and data
assertThat(keyCaptor.getAllValues().get(0)).startsWith("device:code:");
DeviceCodeData data = (DeviceCodeData) valueCaptor.getAllValues().get(0);
assertThat(data.getDeviceCode()).isEqualTo(response.deviceCode());
assertThat(data.getUserCode()).isEqualTo(response.userCode());
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.PENDING);
// Verify user code key and value
assertThat(keyCaptor.getAllValues().get(1)).startsWith("device:usercode:");
assertThat(valueCaptor.getAllValues().get(1)).isEqualTo(response.deviceCode());
}
@Test
void pollToken_returns_pending_when_not_authorized() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null);
when(valueOperations.get("device:code:device123")).thenReturn(data);
// When
DeviceTokenResponse response = service.pollToken("device123");
// Then
assertThat(response.error()).isEqualTo("authorization_pending");
assertThat(response.accessToken()).isNull();
}
@Test
void pollToken_returns_access_token_when_authorized() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
when(valueOperations.get("device:code:device123")).thenReturn(data);
when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(true);
when(apiTokenService.createToken("42", "CLI Device Flow", "[\"skill:read\",\"skill:publish\"]"))
.thenReturn(new ApiTokenService.TokenCreateResult("sk_cli_token", mock(ApiToken.class)));
// When
DeviceTokenResponse response = service.pollToken("device123");
// Then
assertThat(response.accessToken()).isEqualTo("sk_cli_token");
assertThat(response.tokenType()).isEqualTo("Bearer");
assertThat(response.error()).isNull();
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.USED);
verify(valueOperations).set("device:code:device123", data, 1L, TimeUnit.MINUTES);
verify(redisTemplate).delete("device:usercode:ABCD-1234");
}
@Test
void pollToken_rejects_second_exchange_attempt() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
when(valueOperations.get("device:code:device123")).thenReturn(data);
when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(false);
// When / Then
assertThatThrownBy(() -> service.pollToken("device123"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.deviceAuth.deviceCode.used");
verify(apiTokenService, never()).createToken(anyString(), anyString(), anyString());
}
@Test
void pollToken_returns_error_when_expired() {
// Given
when(valueOperations.get("device:code:expired123")).thenReturn(null);
// When / Then
assertThatThrownBy(() -> service.pollToken("expired123"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.deviceAuth.deviceCode.invalid");
}
@Test
void authorizeDeviceCode_updates_status() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null);
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
when(valueOperations.get("device:code:device123")).thenReturn(data);
// When
service.authorizeDeviceCode("ABCD-1234", "42");
// Then
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED);
assertThat(data.getUserId()).isEqualTo("42");
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES));
}
@Test
void authorizeDeviceCode_rejects_different_user_after_authorization() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
when(valueOperations.get("device:code:device123")).thenReturn(data);
// When / Then
assertThatThrownBy(() -> service.authorizeDeviceCode("ABCD-1234", "99"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.deviceAuth.deviceCode.alreadyAuthorized");
}
}

View file

@ -24,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -153,12 +154,30 @@ public class ReviewService {
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
if (skillVersion.getStatus() != SkillVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("review.not_pending", reviewTaskId);
}
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
// Check no other owner has a published skill with the same slug
List<Skill> sameSlugSkills = skillRepository.findByNamespaceIdAndSlug(skill.getNamespaceId(), skill.getSlug());
for (Skill other : sameSlugSkills) {
if (!other.getId().equals(skill.getId())) {
boolean otherHasPublished = !skillVersionRepository
.findBySkillIdAndStatus(other.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (otherHasPublished) {
throw new DomainBadRequestException("error.skill.approve.nameConflict", skill.getSlug());
}
}
}
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
skillVersion.setPublishedAt(LocalDateTime.now());
skillVersionRepository.save(skillVersion);
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
skill.setLatestVersionId(skillVersion.getId());
applyPublishedMetadata(skill, skillVersion);
skill.setUpdatedBy(reviewerId);

View file

@ -7,7 +7,8 @@ public interface SkillRepository {
Optional<Skill> findById(Long id);
List<Skill> findByIdIn(List<Long> ids);
List<Skill> findAll();
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
Skill save(Skill skill);
void delete(Skill skill);

View file

@ -7,6 +7,9 @@ import java.util.Map;
public class VisibilityChecker {
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
if (skill.getLatestVersionId() == null) {
return isOwner(skill, currentUserId);
}
return switch (skill.getVisibility()) {
case PUBLIC -> true;
case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId());

View file

@ -26,6 +26,7 @@ public class SkillDownloadService {
private final ObjectStorageService objectStorageService;
private final VisibilityChecker visibilityChecker;
private final ApplicationEventPublisher eventPublisher;
private final SkillSlugResolutionService skillSlugResolutionService;
public SkillDownloadService(
NamespaceRepository namespaceRepository,
@ -34,7 +35,8 @@ public class SkillDownloadService {
SkillTagRepository skillTagRepository,
ObjectStorageService objectStorageService,
VisibilityChecker visibilityChecker,
ApplicationEventPublisher eventPublisher) {
ApplicationEventPublisher eventPublisher,
SkillSlugResolutionService skillSlugResolutionService) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
@ -42,6 +44,7 @@ public class SkillDownloadService {
this.objectStorageService = objectStorageService;
this.visibilityChecker = visibilityChecker;
this.eventPublisher = eventPublisher;
this.skillSlugResolutionService = skillSlugResolutionService;
}
public record DownloadResult(
@ -59,8 +62,7 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -85,8 +87,7 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -107,8 +108,7 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -155,6 +155,14 @@ public class SkillDownloadService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,
slug,
currentUserId,
SkillSlugResolutionService.Preference.CURRENT_USER);
}
private void assertPublishedAccessible(Skill skill) {
if (skill.getStatus() != SkillStatus.ACTIVE) {
throw new DomainBadRequestException("error.skill.status.notActive");

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
@ -188,8 +189,23 @@ public class SkillPublishService {
String.join(", ", prePublishValidation.errors()));
}
// 6. Find or create Skill record
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
// 6. Find or create Skill record (with owner isolation)
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
// Check if any other owner's skill has published versions
for (Skill existing : existingSkills) {
if (!existing.getOwnerId().equals(publisherId)) {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
}
}
}
// Find or create skill for current user
Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)
.orElseGet(() -> {
Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility);
newSkill.setCreatedBy(publisherId);
@ -200,6 +216,16 @@ public class SkillPublishService {
throw new DomainBadRequestException("error.skill.publish.archived", skillSlug);
}
// 6c. Auto-withdraw pending review versions
List<SkillVersion> pendingVersions = skillVersionRepository
.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW);
for (SkillVersion pending : pendingVersions) {
reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING)
.ifPresent(reviewTaskRepository::delete);
pending.setStatus(SkillVersionStatus.DRAFT);
skillVersionRepository.save(pending);
}
// 7. Check version doesn't already exist
if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) {
throw new DomainBadRequestException("error.skill.version.exists", metadata.version());

View file

@ -40,6 +40,7 @@ public class SkillQueryService {
private final ObjectStorageService objectStorageService;
private final VisibilityChecker visibilityChecker;
private final PromotionRequestRepository promotionRequestRepository;
private final SkillSlugResolutionService skillSlugResolutionService;
public SkillQueryService(
NamespaceRepository namespaceRepository,
@ -49,7 +50,8 @@ public class SkillQueryService {
SkillTagRepository skillTagRepository,
ObjectStorageService objectStorageService,
VisibilityChecker visibilityChecker,
PromotionRequestRepository promotionRequestRepository) {
PromotionRequestRepository promotionRequestRepository,
SkillSlugResolutionService skillSlugResolutionService) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
@ -58,6 +60,7 @@ public class SkillQueryService {
this.objectStorageService = objectStorageService;
this.visibilityChecker = visibilityChecker;
this.promotionRequestRepository = promotionRequestRepository;
this.skillSlugResolutionService = skillSlugResolutionService;
}
public record SkillDetailDTO(
@ -114,8 +117,7 @@ public class SkillQueryService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -182,7 +184,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
@ -207,7 +209,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
@ -223,7 +225,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
return skillFileRepository.findByVersionId(skillVersion.getId());
@ -237,7 +239,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
@ -256,7 +258,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
SkillFile file = findFile(skillVersion, filePath);
@ -269,7 +271,7 @@ public class SkillQueryService {
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
@ -313,7 +315,7 @@ public class SkillQueryService {
}
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash);
String fingerprint = computeFingerprint(resolved);
@ -340,14 +342,17 @@ public class SkillQueryService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
Namespace namespace = findNamespace(namespaceSlug);
return findSkill(namespace, skillSlug);
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
}
private Skill findSkill(Namespace namespace, String skillSlug) {
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,
slug,
currentUserId,
SkillSlugResolutionService.Preference.CURRENT_USER);
}
private SkillVersion findVersion(Skill skill, String version) {

View file

@ -0,0 +1,46 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class SkillSlugResolutionService {
public enum Preference {
CURRENT_USER,
PUBLISHED
}
private final SkillRepository skillRepository;
public SkillSlugResolutionService(SkillRepository skillRepository) {
this.skillRepository = skillRepository;
}
public Skill resolve(Long namespaceId, String slug, String currentUserId, Preference preference) {
List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainBadRequestException("error.skill.notFound", slug);
}
Optional<Skill> ownSkill = currentUserId == null
? Optional.empty()
: skills.stream().filter(skill -> currentUserId.equals(skill.getOwnerId())).findFirst();
Optional<Skill> publishedSkill = skills.stream()
.filter(skill -> skill.getLatestVersionId() != null)
.findFirst();
if (preference == Preference.CURRENT_USER) {
return ownSkill.or(() -> publishedSkill)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug));
}
return publishedSkill.or(() -> ownSkill)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug));
}
}

View file

@ -23,6 +23,7 @@ public class SkillTagService {
private final SkillVersionRepository skillVersionRepository;
private final SkillTagRepository skillTagRepository;
private final VisibilityChecker visibilityChecker;
private final SkillSlugResolutionService skillSlugResolutionService;
public SkillTagService(
NamespaceRepository namespaceRepository,
@ -30,13 +31,15 @@ public class SkillTagService {
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillTagRepository skillTagRepository,
VisibilityChecker visibilityChecker) {
VisibilityChecker visibilityChecker,
SkillSlugResolutionService skillSlugResolutionService) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillTagRepository = skillTagRepository;
this.visibilityChecker = visibilityChecker;
this.skillSlugResolutionService = skillSlugResolutionService;
}
public List<SkillTag> listTags(String namespaceSlug,
@ -44,8 +47,7 @@ public class SkillTagService {
String currentUserId,
java.util.Map<Long, NamespaceRole> userNamespaceRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
@ -76,8 +78,7 @@ public class SkillTagService {
Namespace namespace = findNamespace(namespaceSlug);
assertAdminOrOwner(namespace.getId(), operatorId);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId);
// Find target version
SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion)
@ -111,8 +112,7 @@ public class SkillTagService {
Namespace namespace = findNamespace(namespaceSlug);
assertAdminOrOwner(namespace.getId(), operatorId);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId);
SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName));
@ -125,6 +125,14 @@ public class SkillTagService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,
slug,
currentUserId,
SkillSlugResolutionService.Preference.CURRENT_USER);
}
private void assertAdminOrOwner(Long namespaceId, String operatorId) {
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId)
.map(member -> member.getRole())

View file

@ -29,6 +29,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@ -233,6 +234,7 @@ class ReviewServiceTest {
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.approveReview(
@ -263,6 +265,7 @@ class ReviewServiceTest {
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
@ -316,6 +319,24 @@ class ReviewServiceTest {
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of()));
}
@Test
void shouldRejectApproveWhenSkillVersionWasWithdrawnBackToDraft() {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
sv.setStatus(SkillVersionStatus.DRAFT);
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
assertThrows(DomainBadRequestException.class,
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
}
@Test
void shouldThrowWhenNoPermission() {
ReviewTask task = createPendingReviewTask();
@ -356,6 +377,7 @@ class ReviewServiceTest {
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.approveReview(
@ -379,6 +401,33 @@ class ReviewServiceTest {
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
}
@Test
void shouldRejectApproveWhenOtherOwnerHasPublishedSameSlug() {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill(); // owned by USER_ID
// Another owner's skill with same slug that has a published version
Skill otherSkill = new Skill(NAMESPACE_ID, "my-skill", "other-user", SkillVisibility.PUBLIC);
setField(otherSkill, "id", 99L);
SkillVersion otherPublished = new SkillVersion(99L, "1.0.0", "other-user");
otherPublished.setStatus(SkillVersionStatus.PUBLISHED);
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill, otherSkill));
when(skillVersionRepository.findBySkillIdAndStatus(99L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(otherPublished));
assertThrows(DomainBadRequestException.class,
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
}
}
@Nested

View file

@ -14,6 +14,7 @@ class VisibilityCheckerTest {
private Skill publicSkill;
private Skill namespaceOnlySkill;
private Skill privateSkill;
private Skill unpublishedPublicSkill;
private static final Long NAMESPACE_ID = 1L;
private static final String OWNER_ID = "user-100";
@ -26,8 +27,12 @@ class VisibilityCheckerTest {
checker = new VisibilityChecker();
publicSkill = new Skill(NAMESPACE_ID, "public-skill", OWNER_ID, SkillVisibility.PUBLIC);
publicSkill.setLatestVersionId(10L);
namespaceOnlySkill = new Skill(NAMESPACE_ID, "namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY);
namespaceOnlySkill.setLatestVersionId(11L);
privateSkill = new Skill(NAMESPACE_ID, "private-skill", OWNER_ID, SkillVisibility.PRIVATE);
privateSkill.setLatestVersionId(12L);
unpublishedPublicSkill = new Skill(NAMESPACE_ID, "draft-public-skill", OWNER_ID, SkillVisibility.PUBLIC);
}
@Test
@ -99,4 +104,29 @@ class VisibilityCheckerTest {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of());
assertFalse(canAccess);
}
@Test
void testUnpublishedSkillNotAccessibleByAnonymousEvenWhenPublic() {
boolean canAccess = checker.canAccess(unpublishedPublicSkill, null, Map.of());
assertFalse(canAccess);
}
@Test
void testUnpublishedSkillNotAccessibleByOtherUserEvenWhenPublic() {
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of());
assertFalse(canAccess);
}
@Test
void testUnpublishedSkillNotAccessibleByAdmin() {
Map<Long, NamespaceRole> roles = Map.of(NAMESPACE_ID, NamespaceRole.ADMIN);
boolean canAccess = checker.canAccess(unpublishedPublicSkill, ADMIN_USER_ID, roles);
assertFalse(canAccess);
}
@Test
void testUnpublishedSkillAccessibleByOwner() {
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OWNER_ID, Map.of());
assertTrue(canAccess);
}
}

View file

@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -45,9 +46,11 @@ class SkillDownloadServiceTest {
private ApplicationEventPublisher eventPublisher;
private SkillDownloadService service;
private SkillSlugResolutionService skillSlugResolutionService;
@BeforeEach
void setUp() {
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
service = new SkillDownloadService(
namespaceRepository,
skillRepository,
@ -55,7 +58,8 @@ class SkillDownloadServiceTest {
skillTagRepository,
objectStorageService,
visibilityChecker,
eventPublisher
eventPublisher,
skillSlugResolutionService
);
}
@ -82,7 +86,7 @@ class SkillDownloadServiceTest {
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
when(objectStorageService.exists(storageKey)).thenReturn(true);
@ -124,7 +128,7 @@ class SkillDownloadServiceTest {
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
@ -164,7 +168,7 @@ class SkillDownloadServiceTest {
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version));
when(objectStorageService.exists(storageKey)).thenReturn(true);
@ -196,7 +200,7 @@ class SkillDownloadServiceTest {
version.setStatus(SkillVersionStatus.DRAFT);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version));

View file

@ -109,7 +109,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -162,7 +163,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("smoke-skill-two"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -205,7 +207,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("auto-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -253,7 +256,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(archivedSkill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(archivedSkill));
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
@ -283,7 +287,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
when(skillVersionRepository.findBySkillIdAndVersion(any(), anyString())).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -367,7 +372,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("admin-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -413,7 +419,8 @@ class SkillPublishServiceTest {
Skill skill = new Skill(namespace.getId(), "too-long-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 10L);
when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), "too-long-skill", publisherId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.0.0")).thenReturn(Optional.empty());
when(skillVersionRepository.save(any())).thenAnswer(invocation -> {
SkillVersion version = invocation.getArgument(0);
@ -537,6 +544,133 @@ class SkillPublishServiceTest {
));
}
@Test
void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
// Existing skill owned by another user with a published version
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
));
}
@Test
void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
// Existing skill owned by another user with NO published version
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
setId(existingSkill, 1L);
Skill newSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(newSkill, 2L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.empty());
when(skillRepository.save(any(Skill.class))).thenReturn(newSkill);
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) setId(saved, 10L);
return saved;
});
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
);
assertNotNull(result);
assertEquals("test-skill", result.slug());
}
@Test
void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 2.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of());
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
// Existing pending version
SkillVersion pendingV1 = new SkillVersion(1L, "1.0.0", publisherId);
pendingV1.setStatus(SkillVersionStatus.PENDING_REVIEW);
setId(pendingV1, 5L);
ReviewTask pendingTask = new ReviewTask(5L, 1L, publisherId);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of(pendingV1));
when(reviewTaskRepository.findBySkillVersionIdAndStatus(5L, com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(pendingTask));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("2.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) setId(saved, 10L);
return saved;
});
when(skillRepository.save(any())).thenReturn(skill);
service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of());
// Verify pending version was withdrawn to DRAFT
assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus());
verify(reviewTaskRepository).delete(pendingTask);
verify(skillVersionRepository).save(pendingV1);
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

View file

@ -52,9 +52,11 @@ class SkillQueryServiceTest {
private PromotionRequestRepository promotionRequestRepository;
private SkillQueryService service;
private SkillSlugResolutionService skillSlugResolutionService;
@BeforeEach
void setUp() {
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
service = new SkillQueryService(
namespaceRepository,
skillRepository,
@ -63,7 +65,8 @@ class SkillQueryServiceTest {
skillTagRepository,
objectStorageService,
visibilityChecker,
promotionRequestRepository
promotionRequestRepository,
skillSlugResolutionService
);
}
@ -87,7 +90,7 @@ class SkillQueryServiceTest {
setId(version, 10L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
@ -101,6 +104,41 @@ class SkillQueryServiceTest {
assertEquals("1.0.0", result.latestVersion());
}
@Test
void testGetSkillDetail_PrefersCurrentUsersOwnSkillOverOtherPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill publishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
setId(publishedSkill, 1L);
publishedSkill.setDisplayName("Published Skill");
publishedSkill.setLatestVersionId(11L);
Skill ownSkill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(ownSkill, 2L);
ownSkill.setDisplayName("Own Skill");
ownSkill.setLatestVersionId(22L);
SkillVersion ownVersion = new SkillVersion(2L, "2.0.0", userId);
setId(ownVersion, 22L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
assertEquals(2L, result.id());
assertEquals("Own Skill", result.displayName());
assertEquals("2.0.0", result.latestVersion());
}
@Test
void testGetSkillDetail_AccessDenied() throws Exception {
// Arrange
@ -113,9 +151,10 @@ class SkillQueryServiceTest {
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PRIVATE);
setId(skill, 1L);
skill.setLatestVersionId(11L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
// Act & Assert
@ -134,9 +173,10 @@ class SkillQueryServiceTest {
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setLatestVersionId(11L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
assertThrows(DomainForbiddenException.class, () ->
service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of()));
@ -170,6 +210,51 @@ class SkillQueryServiceTest {
assertEquals("skill1", result.getContent().get(0).getSlug());
}
@Test
void testGetSkillDetail_ShouldHideOtherUsersUnpublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String viewerId = "user-300";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.ADMIN);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill unpublishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
setId(unpublishedSkill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(unpublishedSkill));
assertThrows(DomainBadRequestException.class, () ->
service.getSkillDetail(namespaceSlug, skillSlug, viewerId, userNsRoles));
}
@Test
void testListSkillsByNamespace_ShouldHideOtherUsersUnpublishedSkills() throws Exception {
String namespaceSlug = "test-ns";
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Pageable pageable = PageRequest.of(0, 10);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill ownUnpublishedSkill = new Skill(1L, "own-skill", userId, SkillVisibility.PUBLIC);
setId(ownUnpublishedSkill, 1L);
Skill othersUnpublishedSkill = new Skill(1L, "other-skill", "user-200", SkillVisibility.PUBLIC);
setId(othersUnpublishedSkill, 2L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill));
when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false);
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
assertEquals(1, result.getTotalElements());
assertEquals("own-skill", result.getContent().get(0).getSlug());
}
@Test
void testListFiles() throws Exception {
// Arrange
@ -189,7 +274,7 @@ class SkillQueryServiceTest {
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
@ -219,7 +304,7 @@ class SkillQueryServiceTest {
skillVersion.setStatus(SkillVersionStatus.DRAFT);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
@ -246,7 +331,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
@ -279,7 +364,7 @@ class SkillQueryServiceTest {
skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
@ -313,7 +398,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@ -351,7 +436,7 @@ class SkillQueryServiceTest {
rejected.setStatus(SkillVersionStatus.REJECTED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
@ -385,7 +470,7 @@ class SkillQueryServiceTest {
SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version100, version110));
@ -427,7 +512,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
@ -460,7 +545,7 @@ class SkillQueryServiceTest {
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -487,7 +572,7 @@ class SkillQueryServiceTest {
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
@ -518,7 +603,7 @@ class SkillQueryServiceTest {
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING))
@ -548,7 +633,7 @@ class SkillQueryServiceTest {
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
@ -572,10 +657,16 @@ class SkillQueryServiceTest {
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -607,7 +698,7 @@ class SkillQueryServiceTest {
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW))
.thenReturn(List.of(pending));
@ -642,7 +733,7 @@ class SkillQueryServiceTest {
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
@ -674,7 +765,7 @@ class SkillQueryServiceTest {
pending.setManifestJson("[{\"path\":\"SKILL.md\"}]");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
@ -709,7 +800,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@ -737,9 +828,10 @@ class SkillQueryServiceTest {
SkillVersion pending = new SkillVersion(1L, version, "owner-1");
setId(pending, 11L);
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
skill.setLatestVersionId(10L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
@ -771,7 +863,7 @@ class SkillQueryServiceTest {
rejected.setStatus(SkillVersionStatus.REJECTED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
@ -803,9 +895,10 @@ class SkillQueryServiceTest {
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
skill.setLatestVersionId(11L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));

View file

@ -0,0 +1,69 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SkillSlugResolutionServiceTest {
private final SkillRepository skillRepository = mock(SkillRepository.class);
private final SkillSlugResolutionService service = new SkillSlugResolutionService(skillRepository);
@Test
void prefersCurrentUsersOwnSkillWhenRequested() throws Exception {
Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L);
Skill ownSkill = createSkill(2L, "demo", "user-1", 22L);
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(publishedSkill, ownSkill));
Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER);
assertEquals(2L, resolved.getId());
}
@Test
void prefersPublishedSkillForPublicInteractions() throws Exception {
Skill ownDraft = createSkill(2L, "demo", "user-1", null);
Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L);
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(ownDraft, publishedSkill));
Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.PUBLISHED);
assertEquals(1L, resolved.getId());
}
@Test
void throwsWhenNoSkillMatchesSlug() {
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of());
assertThrows(DomainBadRequestException.class, () ->
service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER));
}
@Test
void throwsWhenOnlyUnpublishedSkillsBelongToOtherUsers() throws Exception {
Skill otherUsersDraft = createSkill(3L, "demo", "user-2", null);
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(otherUsersDraft));
assertThrows(DomainBadRequestException.class, () ->
service.resolve(1L, "demo", null, SkillSlugResolutionService.Preference.CURRENT_USER));
}
private Skill createSkill(Long id, String slug, String ownerId, Long latestVersionId) throws Exception {
Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC);
Field idField = Skill.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(skill, id);
skill.setLatestVersionId(latestVersionId);
return skill;
}
}

View file

@ -39,16 +39,19 @@ class SkillTagServiceTest {
private VisibilityChecker visibilityChecker;
private SkillTagService service;
private SkillSlugResolutionService skillSlugResolutionService;
@BeforeEach
void setUp() {
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
service = new SkillTagService(
namespaceRepository,
namespaceMemberRepository,
skillRepository,
skillVersionRepository,
skillTagRepository,
visibilityChecker
visibilityChecker,
skillSlugResolutionService
);
}
@ -73,7 +76,7 @@ class SkillTagServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER)));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version));
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty());
when(skillTagRepository.save(any())).thenReturn(tag);
@ -118,7 +121,7 @@ class SkillTagServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN)));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
// Act
@ -175,7 +178,7 @@ class SkillTagServiceTest {
SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2));
when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true);

View file

@ -38,10 +38,15 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
}
@Override
public Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
public List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
return delegate.findByNamespaceIdAndSlug(namespaceId, slug);
}
@Override
public Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId) {
return delegate.findByNamespaceIdAndSlugAndOwnerId(namespaceId, slug, ownerId);
}
@Override
public List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
return delegate.findByNamespaceIdAndStatus(namespaceId, status);

View file

@ -18,7 +18,8 @@ import java.util.Optional;
@Repository
public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRepository {
List<Skill> findByIdIn(List<Long> ids);
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
@Override
default List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {

View file

@ -28,14 +28,17 @@
"react-dropzone": "^15.0.0",
"react-i18next": "^16.5.8",
"react-markdown": "^10.1.0",
"remark-frontmatter": "^5.0.0",
"rehype-highlight": "^7.0.2",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.1",
"unist-util-visit": "^5.0.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/mdast": "^4.0.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",

58
web/pnpm-lock.yaml generated
View file

@ -56,6 +56,9 @@ importers:
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
remark-frontmatter:
specifier: ^5.0.0
version: 5.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@ -65,10 +68,16 @@ importers:
tailwind-merge:
specifier: ^2.2.1
version: 2.6.1
unist-util-visit:
specifier: ^5.0.0
version: 5.1.0
zustand:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
'@types/react':
specifier: ^19.0.0
version: 19.2.14
@ -1362,6 +1371,9 @@ packages:
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fault@2.0.1:
resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -1394,6 +1406,10 @@ packages:
flatted@3.4.1:
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@ -1654,6 +1670,9 @@ packages:
mdast-util-from-markdown@2.0.3:
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
mdast-util-frontmatter@2.0.1:
resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
@ -1700,6 +1719,9 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-frontmatter@2.0.0:
resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
@ -2063,6 +2085,9 @@ packages:
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-frontmatter@5.0.0:
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@ -3674,6 +3699,10 @@ snapshots:
dependencies:
reusify: 1.1.0
fault@2.0.1:
dependencies:
format: 0.2.2
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -3703,6 +3732,8 @@ snapshots:
flatted@3.4.1: {}
format@0.2.2: {}
fraction.js@5.3.4: {}
fs.realpath@1.0.0: {}
@ -3970,6 +4001,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-frontmatter@2.0.1:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
escape-string-regexp: 5.0.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
micromark-extension-frontmatter: 2.0.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
@ -4120,6 +4162,13 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-frontmatter@2.0.0:
dependencies:
fault: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
@ -4558,6 +4607,15 @@ snapshots:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-frontmatter@5.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-frontmatter: 2.0.1
micromark-extension-frontmatter: 2.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4

View file

@ -4,6 +4,12 @@ import { Layout } from './layout'
import { getCurrentUser } from '@/api/client'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
// Capture original URL before TanStack Router rewrites it
const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : ''
// Export for use in cli-auth page
export { ORIGINAL_URL_SEARCH }
function createLazyRouteComponent<TModule extends Record<string, unknown>>(
importer: () => Promise<TModule>,
exportName: keyof TModule,
@ -64,7 +70,7 @@ const PromotionsPage = createLazyRouteComponent(
)
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
const DeviceAuthPage = createLazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage')
const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage')
const SecuritySettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/security'),
'SecuritySettingsPage',
@ -269,10 +275,19 @@ const dashboardTokensRoute = createRoute({
component: TokensPage,
})
const deviceRoute = createRoute({
const cliAuthRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'device',
component: DeviceAuthPage,
path: 'cli/auth',
component: CliAuthPage,
validateSearch: (search: Record<string, unknown>): Record<string, string> => {
// Preserve all CLI auth parameters - use empty string instead of undefined to prevent TanStack Router from removing them
return {
redirect_uri: typeof search.redirect_uri === 'string' ? search.redirect_uri : '',
label_b64: typeof search.label_b64 === 'string' ? search.label_b64 : '',
label: typeof search.label === 'string' ? search.label : '',
state: typeof search.state === 'string' ? search.state : '',
}
},
})
const settingsSecurityRoute = createRoute({
@ -337,7 +352,7 @@ const routeTree = rootRoute.addChildren([
dashboardPromotionsRoute,
dashboardStarsRoute,
dashboardTokensRoute,
deviceRoute,
cliAuthRoute,
settingsSecurityRoute,
settingsAccountsRoute,
adminUsersRoute,

View file

@ -43,6 +43,7 @@ export function useUpdateUserRole() {
updateUserRole(userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
@ -54,6 +55,7 @@ export function useUpdateUserStatus() {
updateUserStatus(userId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
@ -64,6 +66,7 @@ export function useApproveUser() {
mutationFn: (userId: string) => adminApi.approveUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
@ -74,6 +77,7 @@ export function useDisableUser() {
mutationFn: (userId: string) => adminApi.disableUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
@ -84,6 +88,7 @@ export function useEnableUser() {
mutationFn: (userId: string) => adminApi.enableUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}

View file

@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { accountApi } from '@/api/client'
import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types'
@ -15,7 +15,11 @@ export function useVerifyAccountMerge() {
}
export function useConfirmAccountMerge() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}

View file

@ -1,13 +1,31 @@
import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeSanitize from 'rehype-sanitize'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import type { Root } from 'mdast'
import { visit } from 'unist-util-visit'
interface MarkdownRendererProps {
content: string
className?: string
}
function remarkStripFrontmatter() {
return (tree: Root) => {
visit(tree, (node, index, parent) => {
if (!parent || index === undefined) {
return
}
const nodeType = String(node.type)
if (nodeType === 'yaml' || nodeType === 'toml') {
parent.children.splice(index, 1)
}
})
}
}
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const containerClassName = [
className,
@ -19,7 +37,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
return (
<div className={containerClassName}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkFrontmatter, remarkStripFrontmatter, remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
components={{
pre: ({ children }) => (

View file

@ -114,7 +114,18 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
const minDateTime = toLocalDateTimeInputValue(new Date())
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(nextOpen) => {
if (nextOpen) {
setCreatedToken(null)
setName('')
setNameError(null)
setExpirationMode('never')
setCustomExpiresAt('')
setExpiresAtError(null)
createMutation.reset()
}
setOpen(nextOpen)
}}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
{!createdToken ? (
@ -210,22 +221,22 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
</>
) : (
<>
<DialogHeader className="text-center sm:text-center">
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{t('createToken.successTitle')}</DialogTitle>
<DialogDescription className="text-center">
<DialogDescription className="text-center break-words">
{t('createToken.successDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="min-w-0 space-y-4 py-4">
<div className="space-y-2">
<Label>{t('createToken.tokenLabel')}</Label>
<div className="rounded-md bg-muted p-3 font-mono text-sm break-all">
<div className="overflow-hidden rounded-md bg-muted p-3 font-mono text-sm break-all">
{createdToken.token}
</div>
</div>
<div className="space-y-2">
<Label>{t('createToken.nameDisplay')}</Label>
<div className="text-sm">{createdToken.name}</div>
<div className="text-sm break-all">{createdToken.name}</div>
</div>
<div className="space-y-2">
<Label>{t('createToken.expiresAtDisplay')}</Label>

View file

@ -93,7 +93,7 @@ export function TokenList() {
return { previousPages }
},
onSuccess: () => {
if (tokenPage && tokenPage.items.length === 1 && page > 0) {
if (tokenPage && tokenPage.items.length === 0 && page > 0) {
setPage(page - 1)
}
setDeleteDialog({ open: false })
@ -204,7 +204,7 @@ export function TokenList() {
<TableBody>
{tokens.map((token) => (
<TableRow key={token.id}>
<TableCell className="font-medium">{token.name}</TableCell>
<TableCell className="font-medium max-w-48 break-all">{token.name}</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{token.tokenPrefix}...
@ -257,9 +257,9 @@ export function TokenList() {
<Dialog open={expirationDialog.open} onOpenChange={(open) => setExpirationDialog((current) => ({ ...current, open }))}>
<DialogContent>
<DialogHeader>
<DialogHeader className="min-w-0">
<DialogTitle>{t('token.editExpirationTitle')}</DialogTitle>
<DialogDescription>
<DialogDescription className="break-all">
{t('token.editExpirationDescription', { name: expirationDialog.tokenName })}
</DialogDescription>
</DialogHeader>

View file

@ -213,6 +213,23 @@
"submit": "Authorize Device",
"notice": "After authorization, the device will have access to your account"
},
"cliAuth": {
"validating": "Validating...",
"pleaseWait": "Please wait",
"creatingToken": "Creating token...",
"almostThere": "Almost there",
"success": "Authorization successful",
"redirecting": "Redirecting to CLI...",
"fallbackInstructions": "If the browser doesn't redirect automatically, please copy the token below:",
"error": "Authorization failed",
"notAuthenticated": "You are not logged in",
"invalidRedirectUri": "Invalid redirect URI",
"missingState": "Missing security parameter",
"windowsUrlBug": "Known Windows issue: the browser opened an incomplete URL. Please copy the full URL from the CLI output (the line starting with 'Opening browser:') and paste it into your browser address bar.",
"tokenCreationFailed": "Token creation failed",
"loginRequired": "Please log in first to authorize CLI access",
"goToLogin": "Go to Login"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "View your account, skills, and access credentials in one place",
@ -845,6 +862,7 @@
"visibilityOptions": {
"public": "Public",
"namespaceOnly": "Namespace Only",
"loggedInUsersOnly": "Logged-in Users Only",
"private": "Private"
},
"file": "Skill Package File",

View file

@ -211,7 +211,24 @@
"defaultError": "授权失败,请检查用户码是否正确",
"submitting": "授权中...",
"submit": "授权设备",
"notice": "授权后,设备将可以访问你的账户"
"notice": "授权后,设备将可以访问你的账户"
},
"cliAuth": {
"validating": "验证中...",
"pleaseWait": "请稍候",
"creatingToken": "创建令牌中...",
"almostThere": "马上就好",
"success": "授权成功",
"redirecting": "正在跳转回 CLI...",
"fallbackInstructions": "如果浏览器未自动跳转,请手动复制以下令牌:",
"error": "授权失败",
"notAuthenticated": "您尚未登录",
"invalidRedirectUri": "无效的回调地址",
"missingState": "缺少安全参数",
"windowsUrlBug": "检测到 Windows 系统的已知问题:浏览器打开的 URL 不完整。请从 CLI 输出中复制完整的 URL(以 'Opening browser:' 开头的那一行),然后粘贴到浏览器地址栏中。",
"tokenCreationFailed": "令牌创建失败",
"loginRequired": "请先登录以授权 CLI 访问",
"goToLogin": "前往登录"
},
"dashboard": {
"title": "Dashboard",
@ -845,6 +862,7 @@
"visibilityOptions": {
"public": "公开",
"namespaceOnly": "仅命名空间",
"loggedInUsersOnly": "仅登录用户可见",
"private": "私有"
},
"file": "技能包文件",

View file

@ -85,6 +85,13 @@
code, pre, kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
/* Hide browser-native password reveal button (conflicts with custom toggle) */
input[type="password"]::-ms-reveal,
input[type="password"]::-ms-clear,
input[type="password"]::-webkit-credentials-auto-fill-button {
display: none;
}
}
/* ─── Dot-grid background texture ─── */

223
web/src/pages/cli-auth.tsx Normal file
View file

@ -0,0 +1,223 @@
import { useState, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { getCurrentUser, tokenApi } from '@/api/client'
import type { User } from '@/api/types'
import { ORIGINAL_URL_SEARCH } from '@/app/router'
// Parse the original URL params captured before TanStack Router rewrites
const ORIGINAL_PARAMS = new URLSearchParams(ORIGINAL_URL_SEARCH)
function isValidRedirectUri(uri: string): boolean {
try {
const url = new URL(uri)
// Only allow localhost/127.0.0.1/::1 on HTTP
const validHosts = ['localhost', '127.0.0.1', '[::1]', '::1']
return url.protocol === 'http:' && validHosts.includes(url.hostname.toLowerCase())
} catch {
return false
}
}
function decodeLabel(labelB64?: string, labelPlain?: string): string {
if (labelB64) {
try {
// Base64-URL decode
const base64 = labelB64.replace(/-/g, '+').replace(/_/g, '/')
return atob(base64)
} catch {
// Fallback to plain label
}
}
return labelPlain || 'CLI token'
}
export function CliAuthPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const [user, setUser] = useState<User | null | undefined>(undefined)
const [status, setStatus] = useState<'validating' | 'creating' | 'redirecting' | 'error'>('validating')
const [errorMessage, setErrorMessage] = useState<string>('')
const [token, setToken] = useState<string>('')
// Use the captured original params from module load time
const redirectUri = ORIGINAL_PARAMS.get('redirect_uri')?.trim() || undefined
const state = ORIGINAL_PARAMS.get('state')?.trim() || undefined
const labelB64 = ORIGINAL_PARAMS.get('label_b64')?.trim() || undefined
const labelPlain = ORIGINAL_PARAMS.get('label')?.trim() || undefined
const label = decodeLabel(labelB64, labelPlain)
// Debug: log search params and raw URL
console.log('CLI Auth - Original search (from router.tsx):', ORIGINAL_URL_SEARCH)
console.log('CLI Auth - Current URL:', typeof window !== 'undefined' ? window.location.href : 'SSR')
console.log('CLI Auth - redirectUri:', redirectUri)
console.log('CLI Auth - state:', state)
console.log('CLI Auth - label:', label)
useEffect(() => {
// Check authentication status
getCurrentUser()
.then((currentUser) => {
setUser(currentUser)
})
.catch(() => {
setUser(null)
})
}, [])
useEffect(() => {
// Once we know the user status, proceed with token creation
if (user === undefined) {
// Still loading
return
}
if (user === null) {
// Not authenticated - user needs to log in
setStatus('error')
setErrorMessage(t('cliAuth.notAuthenticated'))
return
}
// Validate redirect_uri
if (!redirectUri || !isValidRedirectUri(redirectUri)) {
setStatus('error')
setErrorMessage(t('cliAuth.invalidRedirectUri'))
return
}
// Validate state
if (!state) {
setStatus('error')
// Special error message for Windows users with missing state
if (redirectUri && typeof window !== 'undefined' && navigator.platform.includes('Win')) {
setErrorMessage(t('cliAuth.windowsUrlBug'))
} else {
setErrorMessage(t('cliAuth.missingState'))
}
return
}
// Create token and redirect
setStatus('creating')
tokenApi
.createToken({
name: label,
scopes: ['skill:read', 'skill:publish'],
})
.then((response) => {
setToken(response.token)
setStatus('redirecting')
// Construct redirect URL with token in hash fragment
const registryUrl = window.location.origin
const hashParams = new URLSearchParams()
hashParams.set('token', response.token)
hashParams.set('registry', registryUrl)
hashParams.set('state', state)
const redirectUrl = `${redirectUri}#${hashParams.toString()}`
// Redirect to CLI's loopback server
window.location.assign(redirectUrl)
})
.catch((error) => {
setStatus('error')
setErrorMessage(error instanceof Error ? error.message : t('cliAuth.tokenCreationFailed'))
})
}, [user, redirectUri, state, label, t])
if (status === 'validating') {
return (
<div className="min-h-[70vh] flex items-center justify-center p-4">
<Card className="w-full max-w-md p-8 space-y-6 text-center">
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent items-center justify-center shadow-glow mb-2 mx-auto">
<svg className="w-8 h-8 text-primary-foreground animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.validating')}</h1>
<p className="text-muted-foreground">{t('cliAuth.pleaseWait')}</p>
</Card>
</div>
)
}
if (status === 'creating') {
return (
<div className="min-h-[70vh] flex items-center justify-center p-4">
<Card className="w-full max-w-md p-8 space-y-6 text-center">
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent items-center justify-center shadow-glow mb-2 mx-auto">
<svg className="w-8 h-8 text-primary-foreground animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.creatingToken')}</h1>
<p className="text-muted-foreground">{t('cliAuth.almostThere')}</p>
</Card>
</div>
)
}
if (status === 'redirecting') {
return (
<div className="min-h-[70vh] flex items-center justify-center p-4">
<Card className="w-full max-w-md p-8 space-y-6 text-center">
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 items-center justify-center shadow-glow mb-2 mx-auto">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.success')}</h1>
<p className="text-muted-foreground">{t('cliAuth.redirecting')}</p>
{token && (
<div className="mt-6 p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-2">{t('cliAuth.fallbackInstructions')}</p>
<code className="block p-3 bg-background rounded text-xs font-mono break-all">
{token}
</code>
</div>
)}
</Card>
</div>
)
}
// Error state
return (
<div className="min-h-[70vh] flex items-center justify-center p-4 animate-fade-up">
<Card className="w-full max-w-md p-8 space-y-6">
<div className="text-center space-y-3">
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-red-500 to-red-600 items-center justify-center shadow-glow mb-2 mx-auto">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.error')}</h1>
<p className="text-muted-foreground">{errorMessage}</p>
</div>
{user === null && (
<div className="space-y-4">
<p className="text-sm text-center text-muted-foreground">
{t('cliAuth.loginRequired')}
</p>
<Button
className="w-full"
onClick={() => {
const returnTo = `/cli/auth?${searchParams.toString()}`
navigate({ to: '/login', search: { returnTo } })
}}
>
{t('cliAuth.goToLogin')}
</Button>
</div>
)}
</Card>
</div>
)
}

View file

@ -50,6 +50,10 @@ export function PublishPage() {
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const publishMutation = usePublishSkill()
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
? t('publish.visibilityOptions.loggedInUsersOnly')
: t('publish.visibilityOptions.namespaceOnly')
const handleRemoveSelectedFile = () => {
setSelectedFile(null)
@ -153,7 +157,7 @@ export function PublishPage() {
onChange={(e) => setVisibility(e.target.value)}
>
<option value="PUBLIC">{t('publish.visibilityOptions.public')}</option>
<option value="NAMESPACE_ONLY">{t('publish.visibilityOptions.namespaceOnly')}</option>
<option value="NAMESPACE_ONLY">{namespaceOnlyLabel}</option>
<option value="PRIVATE">{t('publish.visibilityOptions.private')}</option>
</Select>
</div>

View file

@ -135,7 +135,7 @@ export function LoginPage() {
onClick={() => setShowPassword((current) => !current)}
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showPassword ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
{fieldErrors.password ? (

View file

@ -108,6 +108,7 @@ export function SkillDetailPage() {
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW'
const canInteract = skill?.canInteract ?? true
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED'
const refreshSkill = () => {
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })
@ -687,7 +688,7 @@ export function SkillDetailPage() {
variant="outline"
size="lg"
onClick={handleDownload}
disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || isPendingPreview}
disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || !isVersionDownloadable}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />

View file

@ -42,9 +42,9 @@ export function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="text-center sm:text-center">
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center">{description}</DialogDescription>}
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
</DialogHeader>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>

View file

@ -246,7 +246,7 @@ export function usePublishSkill() {
skipGlobalErrorHandler: true,
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
},
})
}