From 980fa29d81ac956ea7e3ce2273b8a9c8c58db869 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:28:36 +0800 Subject: [PATCH] feat(skill): review independent published visibility changes Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../design.md | 4 +- .../specs/skill-visibility/spec.md | 2 +- .../portal/SkillVisibilityController.java | 43 ++++++ .../dto/ReviewSkillDetailResponse.java | 10 +- .../skillhub/dto/ReviewTaskResponse.java | 12 +- .../JpaGovernanceQueryRepository.java | 4 +- .../service/ReviewSkillDetailAppService.java | 19 ++- .../service/SkillVisibilityAppService.java | 67 +++++++++ .../V75__skill_visibility_review.sql | 6 + .../src/main/resources/messages.properties | 6 + .../src/main/resources/messages_ru.properties | 6 + .../src/main/resources/messages_zh.properties | 6 + .../skillhub/stream/ScanTaskConsumerTest.java | 9 ++ .../event/SkillVisibilityChangedEvent.java | 7 + .../skillhub/domain/review/ReviewService.java | 17 +++ .../skillhub/domain/review/ReviewTask.java | 30 ++++ .../domain/review/ReviewTaskRepository.java | 2 + .../domain/security/SecurityScanService.java | 15 ++ .../domain/skill/SkillRepository.java | 1 + .../skill/service/SkillPublishService.java | 8 + .../service/SkillReviewSubmitService.java | 12 ++ .../skill/service/SkillVisibilityService.java | 140 ++++++++++++++++++ .../security/SecurityScanServiceTest.java | 15 ++ .../service/SkillVisibilityServiceTest.java | 138 +++++++++++++++++ .../infra/jpa/JpaSkillRepositoryAdapter.java | 5 + .../event/SearchIndexEventListener.java | 7 + .../event/SearchIndexEventListenerTest.java | 9 ++ web/src/api/client.ts | 13 ++ web/src/api/types.ts | 3 + .../skill/skill-visibility-dialog.test.tsx | 66 +++++++++ .../skill/skill-visibility-dialog.tsx | 75 ++++++++++ web/src/i18n/locales/en.json | 18 +++ web/src/i18n/locales/ru.json | 18 +++ web/src/i18n/locales/zh.json | 18 +++ web/src/pages/dashboard/my-skills.tsx | 5 + web/src/pages/dashboard/namespace-reviews.tsx | 2 +- web/src/pages/dashboard/review-detail.tsx | 8 +- web/src/pages/dashboard/reviews.tsx | 2 +- 38 files changed, 818 insertions(+), 10 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillVisibilityController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillVisibilityAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V75__skill_visibility_review.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java create mode 100644 web/src/features/skill/skill-visibility-dialog.test.tsx create mode 100644 web/src/features/skill/skill-visibility-dialog.tsx diff --git a/openspec/changes/change-published-skill-visibility/design.md b/openspec/changes/change-published-skill-visibility/design.md index a0a85cd88..cca94e557 100644 --- a/openspec/changes/change-published-skill-visibility/design.md +++ b/openspec/changes/change-published-skill-visibility/design.md @@ -2,7 +2,7 @@ - 扩展现有 SKILL_METADATA ReviewTask,为范围申请保存原范围、目标范围、绑定已发布版本;不新增独立审核队列。 - 技能行锁协调提交、发布和审核;范围申请不修改 SkillVersion 状态和 latestVersionId。 -- 审批 CAS 保留现有冲突机制。成功改变范围后通过现有发布投影事件刷新搜索;审计区分提交、通过、拒绝、撤回。 +- 审批 CAS 保留现有冲突机制。成功改变范围后通过专用范围变更事件复用现有搜索重建,避免重复发送内容更新通知;审计区分提交、通过、拒绝、撤回。 - Web 复用管理交互与统一审核详情;展示当前范围和申请范围。 - 业务约束在服务代码与测试维护,迁移只增加必要列,不增加 SQL CHECK。 -- 安全扫描前置条件需要先核对当前已发布私有版本行为;不能默默扩大范围绕过扫描。 +- 已发布 PRIVATE 版本没有可复用的安全通过结果时,复用 ScanTask/outbox 对已存 bundle 重新扫描,保留 PUBLISHED 状态。范围申请绑定这一次 taskId;旧结果不能解锁新申请,未完成或不安全结果不能批准。scanner 关闭且需要新扫描时明确拒绝,不改变原范围。 diff --git a/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md b/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md index 05ea594d0..0fe49abb5 100644 --- a/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md +++ b/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md @@ -22,7 +22,7 @@ - **THEN** 请求被拒绝,现有申请和内容不变 ### Requirement: 扩大范围 SHALL 不绕过安全和头像审核 -沿用当前技能内容审核规则及目标受众头像规则。现有 PRIVATE 发布可能未执行安全扫描;实现 SHALL 核对扩大范围的扫描前置条件,不得通过独立范围接口绕过现有公开分发安全要求。 +沿用当前技能内容审核规则及目标受众头像规则。现有 PRIVATE 发布可能未执行安全扫描;实现 SHALL 核对扩大范围的扫描前置条件,不得通过独立范围接口绕过现有公开分发安全要求。没有安全通过结果时 SHALL 直接扫描已存发布包而不改变已发布状态;范围申请 SHALL 绑定扫描 taskId。未完成、失败、不安全或旧扫描结果 SHALL 不能批准;原范围保持有效。需要新扫描但 scanner 未启用时 SHALL 明确拒绝。 #### Scenario: 审核期间发布版本改变 - **WHEN** 范围申请绑定的发布版本或原范围与当前技能不符 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillVisibilityController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillVisibilityController.java new file mode 100644 index 000000000..53435ba74 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillVisibilityController.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillVisibilityAppService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillVisibilityController extends BaseApiController { + private final SkillVisibilityAppService service; + + public SkillVisibilityController(SkillVisibilityAppService service, ApiResponseFactory responses) { + super(responses); this.service = service; + } + + @GetMapping("/{namespace}/{slug}/visibility") + public ApiResponse current(@PathVariable String namespace, + @PathVariable String slug, @RequestAttribute("userId") String actor, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return ok("response.success", service.current(namespace, slug, actor, roles)); + } + + @PostMapping("/{namespace}/{slug}/visibility") + public ApiResponse change(@PathVariable String namespace, + @PathVariable String slug, @Valid @RequestBody ChangeVisibilityRequest request, + @RequestAttribute("userId") String actor, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest http) { + return ok("response.success.updated", service.change(namespace, slug, request.visibility(), actor, + roles, AuditRequestContext.from(http))); + } + + public record ChangeVisibilityRequest(@NotNull SkillVisibility visibility) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java index bbe77691b..90a818bb9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java @@ -10,8 +10,16 @@ public record ReviewSkillDetailResponse( String documentationContent, String downloadUrl, String activeVersion, - AvatarChange avatarChange + AvatarChange avatarChange, + VisibilityChange visibilityChange ) { + public ReviewSkillDetailResponse(SkillDetailResponse skill, List versions, + List files, String documentationPath, String documentationContent, + String downloadUrl, String activeVersion, AvatarChange avatarChange) { + this(skill, versions, files, documentationPath, documentationContent, downloadUrl, activeVersion, avatarChange, null); + } + public record VisibilityChange(String previousVisibility, String requestedVisibility, + String scanStatus, boolean readyForApproval) {} public record AvatarChange(Long candidateId, String currentAvatarUrl, String candidatePreviewUrl, String machineStatus, String reviewStatus, boolean readyForApproval, boolean removal) {} public ReviewSkillDetailResponse(SkillDetailResponse skill, List versions, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java index a3dc437a6..ac064064e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java @@ -21,8 +21,18 @@ public record ReviewTaskResponse( Long subjectVersionId, String subjectSlug, Long avatarCandidateId, - boolean avatarRemoval + boolean avatarRemoval, + String previousVisibility, + String requestedVisibility ) { + public ReviewTaskResponse(Long id, Long skillVersionId, String namespace, String skillSlug, String version, + String status, String submittedBy, String submittedByName, String reviewedBy, String reviewedByName, + String reviewComment, Instant submittedAt, Instant reviewedAt, String subjectType, Long subjectId, + Long subjectVersionId, String subjectSlug, Long avatarCandidateId, boolean avatarRemoval) { + this(id, skillVersionId, namespace, skillSlug, version, status, submittedBy, submittedByName, + reviewedBy, reviewedByName, reviewComment, submittedAt, reviewedAt, subjectType, subjectId, + subjectVersionId, subjectSlug, avatarCandidateId, avatarRemoval, null, null); + } public ReviewTaskResponse(Long id, Long skillVersionId, String namespace, String skillSlug, String version, String status, String submittedBy, String submittedByName, String reviewedBy, String reviewedByName, String reviewComment, Instant submittedAt, Instant reviewedAt, String subjectType, Long subjectId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index b47e5722a..7a9ed2536 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -251,7 +251,9 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { task.getReviewedAt(), subjectType(task), task.getSubjectId() != null ? task.getSubjectId() : skillId, task.getSubjectVersionId() != null ? task.getSubjectVersionId() : task.getSkillVersionId(), - skill.getSlug(), task.getAvatarCandidateId(), task.isAvatarRemoval() + skill.getSlug(), task.getAvatarCandidateId(), task.isAvatarRemoval(), + task.getPreviousVisibility() == null ? null : task.getPreviousVisibility().name(), + task.getRequestedVisibility() == null ? null : task.getRequestedVisibility().name() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 92bf1d6c1..2fe0edaa6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -47,6 +47,11 @@ public class ReviewSkillDetailAppService { private final SkillDownloadService skillDownloadService; private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService; private final ObjectReader metadataReader; + private com.iflytek.skillhub.domain.security.SecurityAuditRepository securityAudits; + @Autowired + public void configureSecurityAudits(com.iflytek.skillhub.domain.security.SecurityAuditRepository audits) { + this.securityAudits = audits; + } private SkillRepository skills; private SkillAvatarCandidateJpaRepository candidates; @@ -221,7 +226,19 @@ public class ReviewSkillDetailAppService { skill.getRatingCount(), skill.isHidden(), context.namespace().getSlug(), List.of(), false, false, false, false, null, null, null, null, "REVIEW_TASK", List.of(), new PageResponse<>(List.of(), 0, 0, 20), SkillAvatarUrls.current(skill, context.namespace().getSlug())); - return new ReviewSkillDetailResponse(detail, List.of(), List.of(), null, null, null, null, avatarChange(context)); + return new ReviewSkillDetailResponse(detail, List.of(), List.of(), null, null, null, null, + avatarChange(context), visibilityChange(context.task())); + } + + private ReviewSkillDetailResponse.VisibilityChange visibilityChange(ReviewTask task) { + if (task.getRequestedVisibility() == null) { return null; } + var scan = securityAudits.findLatestActiveByVersionIdAndScannerType(task.getVisibilityVersionId(), + com.iflytek.skillhub.domain.security.ScannerType.SKILL_SCANNER) + .filter(audit -> java.util.Objects.equals(task.getVisibilityScanTaskId(), audit.getTaskId())); + String status = scan.isEmpty() ? "FAILED" : scan.get().getScannedAt() == null ? "PENDING" + : scan.get().getFailureReason() == null && Boolean.TRUE.equals(scan.get().getIsSafe()) ? "PASSED" : "FAILED"; + return new ReviewSkillDetailResponse.VisibilityChange(task.getPreviousVisibility().name(), + task.getRequestedVisibility().name(), status, "PASSED".equals(status)); } private ReviewAccessContext loadAuthorizedContext(Long reviewId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillVisibilityAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillVisibilityAppService.java new file mode 100644 index 000000000..9a3cf9743 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillVisibilityAppService.java @@ -0,0 +1,67 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.domain.skill.service.SkillVisibilityService; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillVisibilityAppService { + private final NamespaceRepository namespaces; + private final SkillSlugResolutionService resolver; + private final SkillVisibilityService visibility; + private final RbacService rbac; + private final SkillAvatarAppService avatars; + private final AuditLogService audit; + private final RequestIdAccessor requestIds; + + public SkillVisibilityAppService(NamespaceRepository namespaces, SkillSlugResolutionService resolver, + SkillVisibilityService visibility, RbacService rbac, SkillAvatarAppService avatars, + AuditLogService audit, RequestIdAccessor requestIds) { + this.namespaces = namespaces; this.resolver = resolver; this.visibility = visibility; + this.rbac = rbac; this.avatars = avatars; this.audit = audit; this.requestIds = requestIds; + } + + @Transactional + public Result change(String namespace, String slug, SkillVisibility target, String actor, + Map roles, AuditRequestContext context) { + String cleanNamespace = namespace.startsWith("@") ? namespace.substring(1) : namespace; + var ns = namespaces.findBySlug(cleanNamespace) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", cleanNamespace)); + var skill = resolver.resolve(ns.getId(), slug, actor, SkillSlugResolutionService.Preference.CURRENT_USER); + skill = visibility.lockedSkill(skill.getId()); + var previous = skill.getVisibility(); + var task = visibility.change(skill.getId(), target, actor, roles == null ? Map.of() : roles, + rbac.getUserRoleCodes(actor)); + if (task != null) { + avatars.attachScopeToReview(skill.getId(), target, task, actor, context); + } + audit.record(actor, task == null ? "SKILL_VISIBILITY_CHANGED" : "SKILL_VISIBILITY_SUBMITTED", "SKILL", + skill.getId(), requestIds.current(), context.clientIp(), context.userAgent(), + AuditDetail.builder().put("previousVisibility", previous.name()).put("targetVisibility", target.name()) + .put("reviewTaskId", task == null ? null : task.getId()).build()); + return new Result(task == null ? target : previous, task == null ? null : target, + task == null ? null : task.getId()); + } + + @Transactional(readOnly = true) + public Result current(String namespace, String slug, String actor, Map roles) { + String cleanNamespace = namespace.startsWith("@") ? namespace.substring(1) : namespace; + var ns = namespaces.findBySlug(cleanNamespace) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", cleanNamespace)); + var skill = resolver.resolve(ns.getId(), slug, actor, SkillSlugResolutionService.Preference.CURRENT_USER); + var task = visibility.pending(skill.getId(), actor, roles == null ? Map.of() : roles, rbac.getUserRoleCodes(actor)); + return new Result(skill.getVisibility(), task == null ? null : task.getRequestedVisibility(), task == null ? null : task.getId()); + } + + public record Result(SkillVisibility visibility, SkillVisibility requestedVisibility, Long reviewTaskId) {} +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V75__skill_visibility_review.sql b/server/skillhub-app/src/main/resources/db/migration/V75__skill_visibility_review.sql new file mode 100644 index 000000000..af085ecf5 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V75__skill_visibility_review.sql @@ -0,0 +1,6 @@ +ALTER TABLE review_task ADD COLUMN previous_visibility VARCHAR(20); +ALTER TABLE review_task ADD COLUMN requested_visibility VARCHAR(20); +ALTER TABLE review_task ADD COLUMN visibility_version_id BIGINT; +ALTER TABLE review_task ADD COLUMN visibility_scan_task_id VARCHAR(100); +CREATE INDEX idx_review_task_pending_visibility ON review_task(skill_id, status) + WHERE requested_visibility IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 197d47424..83b37dad5 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -340,3 +340,9 @@ error.skill.metadata.displayName.invalid=metadata.display-name must be a string error.skill.metadata.displayName.tooLong=metadata.display-name must not exceed {0} characters error.skill.displayName.tooLong=Skill display name must not exceed {0} characters +error.skill.visibility.unchanged=Choose a different visibility. +error.skill.visibility.pending=A version or visibility request is pending. Complete or withdraw it first. +error.skill.visibility.stale=Visibility or the published version changed. Withdraw and submit a new request. +error.skill.visibility.notPublished=Only active published skills in an active namespace can change visibility. +error.skill.visibility.scanUnavailable=The published bundle is unavailable for a security scan. +error.skill.visibility.scanIncomplete=The current security scan has not passed. Wider access cannot be approved. diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 605228256..73d40908f 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -283,3 +283,9 @@ error.skill.metadata.displayName.invalid=metadata.display-name должно бы error.skill.metadata.displayName.tooLong=metadata.display-name не должно превышать {0} символов error.skill.displayName.tooLong=Отображаемое имя навыка не должно превышать {0} символов +error.skill.visibility.unchanged=Выберите другой уровень видимости. +error.skill.visibility.pending=Заявка на версию или видимость ожидает проверки. Сначала завершите или отзовите её. +error.skill.visibility.stale=Видимость или опубликованная версия изменилась. Отзовите заявку и создайте новую. +error.skill.visibility.notPublished=Видимость можно менять только у активного опубликованного навыка в активном пространстве. +error.skill.visibility.scanUnavailable=Опубликованный пакет недоступен для проверки безопасности. +error.skill.visibility.scanIncomplete=Текущая проверка безопасности не пройдена. Расширение доступа нельзя одобрить. diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 854abbf5d..d33f01e26 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -342,3 +342,9 @@ error.skill.metadata.displayName.invalid=metadata.display-name 必须是字符 error.skill.metadata.displayName.tooLong=metadata.display-name 不能超过 {0} 个字符 error.skill.displayName.tooLong=技能显示名称不能超过 {0} 个字符 +error.skill.visibility.unchanged=请选择不同的可见范围。 +error.skill.visibility.pending=已有版本或可见范围申请等待审核,请先完成或撤回后重试。 +error.skill.visibility.stale=技能范围或发布版本已变化,请撤回旧申请后重新提交。 +error.skill.visibility.notPublished=仅正常空间内正常、可见且已发布的技能可以修改可见范围。 +error.skill.visibility.scanUnavailable=该发布版本的完整包尚不可用,无法发起安全扫描。 +error.skill.visibility.scanIncomplete=本次安全扫描尚未通过,无法批准扩大范围。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 47afd1efa..5ac27e01c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -813,6 +813,15 @@ class ScanTaskConsumerTest { } private static final class InMemoryReviewTaskRepository implements ReviewTaskRepository { + @Override + public Optional findBySkillIdAndRequestedVisibilityIsNotNullAndStatus(Long skillId, ReviewTaskStatus status) { + return existsBySkillIdAndRequestedVisibilityIsNotNullAndStatus(skillId, status) ? Optional.of(savedTask) : Optional.empty(); + } + @Override + public boolean existsBySkillIdAndRequestedVisibilityIsNotNullAndStatus(Long skillId, ReviewTaskStatus status) { + return savedTask != null && java.util.Objects.equals(skillId, savedTask.getSkillId()) + && savedTask.getRequestedVisibility() != null && savedTask.getStatus() == status; + } private ReviewTask savedTask; private ReviewTask deletedTask; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java new file mode 100644 index 000000000..522c5fe6d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.event; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +/** Access changed without publishing a new package version. */ +public record SkillVisibilityChangedEvent(Long skillId, SkillVisibility previousVisibility, + SkillVisibility visibility) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 63149ed08..be0fad5ba 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -57,6 +57,13 @@ public class ReviewService { private final EntityManager entityManager; private final Clock clock; private ReviewAvatarParticipant avatarParticipant; + private com.iflytek.skillhub.domain.skill.service.SkillVisibilityService visibilityService; + + @Autowired + public void setVisibilityService(com.iflytek.skillhub.domain.skill.service.SkillVisibilityService service) { + this.visibilityService = service; + } + @Autowired public void setAvatarParticipant(ReviewAvatarParticipant participant) { this.avatarParticipant = participant; } @@ -108,6 +115,10 @@ public class ReviewService { throw new DomainForbiddenException("review.submit.no_permission"); } + if (visibilityService != null) { + visibilityService.lockedSkill(skill.getId()); + visibilityService.assertNoPendingVisibility(skill.getId()); + } // Support both DRAFT (legacy) and UPLOADED (new flow) status if (skillVersion.getStatus() != SkillVersionStatus.DRAFT && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { @@ -147,6 +158,10 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); assertNamespaceActive(namespace); + if (visibilityService != null) { + visibilityService.lockedSkill(skill.getId()); + visibilityService.assertNoPendingVisibility(skill.getId()); + } // Support both DRAFT (legacy) and UPLOADED (new flow) status if (skillVersion.getStatus() != SkillVersionStatus.DRAFT && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { @@ -206,6 +221,7 @@ public class ReviewService { throw new DomainForbiddenException("review.no_permission"); } + if (task.getRequestedVisibility() != null) { visibilityService.validateApproval(task); } if (avatarParticipant != null) { avatarParticipant.validateApproval(task, comment); } if (task.getSubjectType() == ReviewSubjectType.SKILL_METADATA) { return decideMetadata(task, reviewerId, comment, true); @@ -370,6 +386,7 @@ public class ReviewService { throw new ConcurrentModificationException("Review task was modified concurrently"); } syncReviewTaskState(task, status, reviewer, comment); + if (task.getRequestedVisibility() != null && approved) { visibilityService.approve(task, reviewer); } avatarParticipant.decide(task, approved, reviewer, comment); governanceNotificationService.notifyUser(task.getSubmittedBy(), "REVIEW", "REVIEW_TASK", task.getId(), approved ? "Review approved" : "Review rejected", AuditDetail.of("status", status.name())); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java index 2cafb29ce..abb5eb0d3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java @@ -33,6 +33,20 @@ public class ReviewTask { @Column(name = "subject_version", nullable = false, length = 64) private String subjectVersion; + @Enumerated(EnumType.STRING) + @Column(name = "previous_visibility", length = 20) + private com.iflytek.skillhub.domain.skill.SkillVisibility previousVisibility; + + @Enumerated(EnumType.STRING) + @Column(name = "requested_visibility", length = 20) + private com.iflytek.skillhub.domain.skill.SkillVisibility requestedVisibility; + + @Column(name = "visibility_scan_task_id", length = 100) + private String visibilityScanTaskId; + + @Column(name = "visibility_version_id") + private Long visibilityVersionId; + @Column(name = "avatar_candidate_id") private Long avatarCandidateId; @@ -122,6 +136,22 @@ public class ReviewTask { return task; } + public static ReviewTask forVisibility(com.iflytek.skillhub.domain.skill.Skill skill, + com.iflytek.skillhub.domain.skill.SkillVisibility target, String actor) { + ReviewTask task = forSkillMetadata(skill.getId(), skill.getNamespaceId(), actor, null); + task.subjectVersion = "visibility:" + java.util.UUID.randomUUID(); + task.previousVisibility = skill.getVisibility(); + task.requestedVisibility = target; + task.visibilityVersionId = skill.getLatestVersionId(); + return task; + } + + public com.iflytek.skillhub.domain.skill.SkillVisibility getPreviousVisibility() { return previousVisibility; } + public com.iflytek.skillhub.domain.skill.SkillVisibility getRequestedVisibility() { return requestedVisibility; } + public String getVisibilityScanTaskId() { return visibilityScanTaskId; } + public void setVisibilityScanTaskId(String taskId) { visibilityScanTaskId = taskId; } + public Long getVisibilityVersionId() { return visibilityVersionId; } + public Long getAvatarCandidateId() { return avatarCandidateId; } public void setAvatarCandidateId(Long candidateId) { this.avatarCandidateId = candidateId; } public Long getAvatarRemovalActiveId() { return avatarRemovalActiveId; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 62b6e1e72..1b7f05a4c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -11,6 +11,8 @@ import java.util.Optional; */ public interface ReviewTaskRepository { default List findByAvatarCandidateIdAndStatus(Long candidateId, ReviewTaskStatus status) { return List.of(); } + Optional findBySkillIdAndRequestedVisibilityIsNotNullAndStatus(Long skillId, ReviewTaskStatus status); + boolean existsBySkillIdAndRequestedVisibilityIsNotNullAndStatus(Long skillId, ReviewTaskStatus status); ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 507bb8f14..552a3737c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -71,6 +71,21 @@ public class SecurityScanService { return enabled; } + /** Scans an immutable published package without changing its availability or version state. */ + @Transactional + public ScanTask scanPublishedVersion(SkillVersion version, String publisherId) { + if (!enabled) { throw new IllegalStateException("Security scanner is disabled"); } + if (version.getStatus() != SkillVersionStatus.PUBLISHED || !version.isBundleReady()) { + throw new IllegalStateException("Only published bundled versions can be scanned"); + } + ScanTask task = new ScanTask(UUID.randomUUID().toString(), version.getId(), null, + "packages/" + version.getSkillId() + "/" + version.getId() + "/bundle.zip", + publisherId, System.currentTimeMillis(), + Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue())); + persistScanAttempt(version, task); + return task; + } + @Transactional public ScanTask retryStoredBundleScan(SkillVersion version, String bundleKey, String publisherId) { if (!enabled) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 5794656fb..d051054cb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -11,6 +11,7 @@ import java.util.Optional; */ public interface SkillRepository { Optional findById(Long id); + Optional findLockedById(Long id); List findByIdIn(List ids); List findAll(); List findByNamespaceIdAndSlug(Long namespaceId, String slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 0e4893311..7539363d9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -80,6 +80,10 @@ public class SkillPublishService { private static final Logger log = LoggerFactory.getLogger(SkillPublishService.class); private ReviewAvatarParticipant avatarParticipant; + private SkillVisibilityService visibilityService; + @org.springframework.beans.factory.annotation.Autowired + public void setVisibilityService(SkillVisibilityService service) { this.visibilityService = service; } + @Autowired public void setAvatarParticipant(ReviewAvatarParticipant participant) { @@ -540,6 +544,10 @@ public class SkillPublishService { : resolveBundleTargetSkill(namespace, existingSkills, publisherId, visibility, platformRoles, bundleTarget); + if (visibilityService != null) { + visibilityService.lockedSkill(skill.getId()); + visibilityService.assertNoPendingVisibility(skill.getId()); + } if (skill.getStatus() == SkillStatus.ARCHIVED) { throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index 2f912d63b..84d737651 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -40,6 +40,10 @@ public class SkillReviewSubmitService { private final NamespaceMemberRepository namespaceMemberRepository; private final ApplicationEventPublisher eventPublisher; private final Clock clock; + private SkillVisibilityService visibilityService; + @org.springframework.beans.factory.annotation.Autowired + public void setVisibilityService(SkillVisibilityService service) { this.visibilityService = service; } + public SkillReviewSubmitService( SkillRepository skillRepository, @@ -76,6 +80,10 @@ public class SkillReviewSubmitService { SkillVersion version = skillVersionRepository.findById(versionId) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + if (visibilityService != null) { + skill = visibilityService.lockedSkill(skill.getId()); + visibilityService.assertNoPendingVisibility(skill.getId()); + } // Validate ownership assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); @@ -127,6 +135,10 @@ public class SkillReviewSubmitService { SkillVersion version = skillVersionRepository.findById(versionId) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + if (visibilityService != null) { + skill = visibilityService.lockedSkill(skill.getId()); + visibilityService.assertNoPendingVisibility(skill.getId()); + } // Validate ownership assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java new file mode 100644 index 000000000..e0a7fce21 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.event.SkillVisibilityChangedEvent; +import com.iflytek.skillhub.domain.security.*; +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.review.*; +import com.iflytek.skillhub.domain.shared.exception.*; +import com.iflytek.skillhub.domain.skill.*; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Changes access scope without changing the published package or version identity. */ +@Service +public class SkillVisibilityService { + @jakarta.persistence.PersistenceContext + private jakarta.persistence.EntityManager entityManager; + private final SkillRepository skills; + private final SkillVersionRepository versions; + private final ReviewTaskRepository reviews; + private final NamespaceRepository namespaces; + private final ApplicationEventPublisher events; + private final SecurityScanService scanner; + private final SecurityAuditRepository audits; + + public SkillVisibilityService(SkillRepository skills, SkillVersionRepository versions, + ReviewTaskRepository reviews, NamespaceRepository namespaces, ApplicationEventPublisher events, + SecurityScanService scanner, SecurityAuditRepository audits) { + this.skills = skills; + this.versions = versions; + this.reviews = reviews; + this.namespaces = namespaces; + this.events = events; + this.scanner = scanner; + this.audits = audits; + } + + @Transactional + public ReviewTask change(Long skillId, SkillVisibility target, String actor, + Map roles, Set platformRoles) { + Skill skill = lockedSkill(skillId); + assertCanManage(skill, actor, roles, platformRoles); + assertActive(skill); + if (target == null || target == skill.getVisibility()) { + throw new DomainBadRequestException("error.skill.visibility.unchanged"); + } + assertNoPendingVisibility(skillId); + if (!versions.findBySkillIdAndStatus(skillId, SkillVersionStatus.PENDING_REVIEW).isEmpty() + || !versions.findBySkillIdAndStatus(skillId, SkillVersionStatus.SCANNING).isEmpty()) { + throw new DomainConflictException("error.skill.visibility.pending"); + } + if (target.ordinal() > skill.getVisibility().ordinal()) { + apply(skill, target, actor); + return null; + } + ReviewTask task = ReviewTask.forVisibility(skill, target, actor); + var previousScan = audits.findLatestActiveByVersionIdAndScannerType(skill.getLatestVersionId(), ScannerType.SKILL_SCANNER); + if (previousScan.filter(audit -> audit.getTaskId() != null).filter(this::safeScan).isPresent()) { + task.setVisibilityScanTaskId(previousScan.get().getTaskId()); + } else { + if (!scanner.isEnabled()) { throw new DomainBadRequestException("error.security.scanner.required"); } + var version = versions.findById(skill.getLatestVersionId()).orElseThrow(); + if (!version.isBundleReady()) { throw new DomainBadRequestException("error.skill.visibility.scanUnavailable"); } + task.setVisibilityScanTaskId(scanner.scanPublishedVersion(version, actor).taskId()); + } + return reviews.save(task); + } + + public ReviewTask pending(Long skillId, String actor, Map roles, Set platformRoles) { + Skill skill = skills.findById(skillId).orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId)); + assertCanManage(skill, actor, roles, platformRoles); + return reviews.findBySkillIdAndRequestedVisibilityIsNotNullAndStatus(skillId, ReviewTaskStatus.PENDING).orElse(null); + } + + private void assertCanManage(Skill skill, String actor, Map roles, Set platformRoles) { + NamespaceRole role = roles.get(skill.getNamespaceId()); + if (!Objects.equals(actor, skill.getOwnerId()) && role != NamespaceRole.OWNER + && role != NamespaceRole.ADMIN && !platformRoles.contains("SUPER_ADMIN")) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } + + public void assertNoPendingVisibility(Long skillId) { + if (reviews.existsBySkillIdAndRequestedVisibilityIsNotNullAndStatus(skillId, ReviewTaskStatus.PENDING)) { + throw new DomainConflictException("error.skill.visibility.pending"); + } + } + + public Skill lockedSkill(Long id) { + Skill skill = skills.findLockedById(id).orElseThrow(() -> new DomainNotFoundException("skill.not_found", id)); + if (entityManager != null) { entityManager.refresh(skill); } + return skill; + } + + public void validateApproval(ReviewTask task) { + Skill skill = lockedSkill(task.getSkillId()); + assertActive(skill); + if (task.getVisibilityScanTaskId() == null || audits + .findLatestActiveByVersionIdAndScannerType(task.getVisibilityVersionId(), ScannerType.SKILL_SCANNER) + .filter(audit -> task.getVisibilityScanTaskId().equals(audit.getTaskId())) + .filter(this::safeScan).isEmpty()) { + throw new DomainConflictException("error.skill.visibility.scanIncomplete"); + } + if (skill.getVisibility() != task.getPreviousVisibility() + || !Objects.equals(skill.getLatestVersionId(), task.getVisibilityVersionId())) { + throw new DomainConflictException("error.skill.visibility.stale"); + } + } + + public void approve(ReviewTask task, String actor) { + validateApproval(task); + apply(lockedSkill(task.getSkillId()), task.getRequestedVisibility(), actor); + } + + private void assertActive(Skill skill) { + Namespace namespace = namespaces.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE + || skill.isHidden() || skill.getLatestVersionId() == null + || versions.findById(skill.getLatestVersionId()) + .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED).isEmpty()) { + throw new DomainBadRequestException("error.skill.visibility.notPublished"); + } + } + + private boolean safeScan(SecurityAudit audit) { + return audit.getScannedAt() != null && audit.getFailureReason() == null && Boolean.TRUE.equals(audit.getIsSafe()); + } + + private void apply(Skill skill, SkillVisibility target, String actor) { + SkillVisibility previous = skill.getVisibility(); + skill.setVisibility(target); + skill.setUpdatedBy(actor); + skills.save(skill); + events.publishEvent(new SkillVisibilityChangedEvent(skill.getId(), previous, target)); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java index eca729627..64aa46209 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java @@ -61,6 +61,21 @@ class SecurityScanServiceTest { } } + @Test + void scanPublishedVersionUsesStoredBundleAndKeepsPublishedState() throws Exception { + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setBundleReady(true); + ScanTask task = service.scanPublishedVersion(version, "publisher-1"); + assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip"); + assertThat(task.skillPath()).isNull(); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + verify(skillVersionRepository, never()).save(any()); + verify(scanTaskProducer).publishScanTask(task); + verify(auditRepository).save(any(SecurityAudit.class)); + } + @Test void securityAudit_startsWithSuspiciousUnsafeDefaults() { SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java new file mode 100644 index 000000000..bc3924a65 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java @@ -0,0 +1,138 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.review.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.event.SkillVisibilityChangedEvent; +import com.iflytek.skillhub.domain.security.*; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SkillVisibilityServiceTest { + private final SkillRepository skills = mock(SkillRepository.class); + private final SkillVersionRepository versions = mock(SkillVersionRepository.class); + private final ReviewTaskRepository reviews = mock(ReviewTaskRepository.class); + private final NamespaceRepository namespaces = mock(NamespaceRepository.class); + private final ApplicationEventPublisher events = mock(ApplicationEventPublisher.class); + private final SecurityScanService scanner = mock(SecurityScanService.class); + private final SecurityAuditRepository audits = mock(SecurityAuditRepository.class); + private final SkillVisibilityService service = new SkillVisibilityService(skills, versions, reviews, namespaces, events, scanner, audits); + private Skill skill; + + @BeforeEach + void setup() throws Exception { + skill = new Skill(2L, "foo", "owner", SkillVisibility.PUBLIC); + var id = Skill.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(skill, 1L); + skill.setLatestVersionId(3L); + when(skills.findLockedById(1L)).thenReturn(Optional.of(skill)); + Namespace ns = mock(Namespace.class); + when(ns.getStatus()).thenReturn(NamespaceStatus.ACTIVE); + when(namespaces.findById(2L)).thenReturn(Optional.of(ns)); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + when(versions.findById(3L)).thenReturn(Optional.of(version)); + SecurityAudit audit = new SecurityAudit(3L, ScannerType.SKILL_SCANNER, "scan-1"); + audit.setScannedAt(java.time.Instant.now()); + audit.setIsSafe(true); + when(audits.findLatestActiveByVersionIdAndScannerType(3L, ScannerType.SKILL_SCANNER)).thenReturn(Optional.of(audit)); + when(reviews.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void narrowingPreservesPublishedVersionAndAppliesImmediately() { + assertThat(service.change(1L, SkillVisibility.PRIVATE, "owner", Map.of(), Set.of())).isNull(); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + assertThat(skill.getLatestVersionId()).isEqualTo(3L); + verify(versions, never()).save(any()); + verify(reviews, never()).save(any()); + verify(events).publishEvent(any(SkillVisibilityChangedEvent.class)); + } + + @Test + void expansionKeepsOldScopeUntilApproved() { + skill.setVisibility(SkillVisibility.NAMESPACE_ONLY); + var task = service.change(1L, SkillVisibility.PUBLIC, "owner", Map.of(), Set.of()); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.NAMESPACE_ONLY); + assertThat(task.getRequestedVisibility()).isEqualTo(SkillVisibility.PUBLIC); + assertThat(task.getVisibilityVersionId()).isEqualTo(3L); + assertThat(task.getSkillVersionId()).isNull(); + service.approve(task, "admin"); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PUBLIC); + verify(versions, never()).save(any()); + } + + @Test + void pendingScopeBlocksSecondChange() { + when(reviews.existsBySkillIdAndRequestedVisibilityIsNotNullAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(true); + assertThatThrownBy(() -> service.change(1L, SkillVisibility.PRIVATE, "owner", Map.of(), Set.of())) + .hasMessageContaining("error.skill.visibility.pending"); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PUBLIC); + } + + @Test + void pendingVersionBlocksChange() { + when(versions.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of(new SkillVersion(1L, "2.0.0", "owner"))); + assertThatThrownBy(() -> service.change(1L, SkillVisibility.PRIVATE, "owner", Map.of(), Set.of())) + .hasMessageContaining("error.skill.visibility.pending"); + } + + @Test + void staleApprovalCannotOverwriteNewPublishedVersion() { + skill.setVisibility(SkillVisibility.PRIVATE); + var task = ReviewTask.forVisibility(skill, SkillVisibility.PUBLIC, "owner"); + task.setVisibilityScanTaskId("scan-1"); + skill.setLatestVersionId(4L); + SkillVersion newer = new SkillVersion(1L, "2.0.0", "owner"); + newer.setStatus(SkillVersionStatus.PUBLISHED); + when(versions.findById(4L)).thenReturn(Optional.of(newer)); + assertThatThrownBy(() -> service.approve(task, "admin")).hasMessageContaining("error.skill.visibility.stale"); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + } + + @Test + void privateExpansionStartsScanAndCannotApproveUntilItsResultPasses() { + skill.setVisibility(SkillVisibility.PRIVATE); + when(audits.findLatestActiveByVersionIdAndScannerType(3L, ScannerType.SKILL_SCANNER)).thenReturn(Optional.empty()); + when(scanner.isEnabled()).thenReturn(true); + versions.findById(3L).orElseThrow().setBundleReady(true); + when(scanner.scanPublishedVersion(any(), eq("owner"))).thenReturn(new ScanTask("new-scan", 3L, null, + "packages/1/3/bundle.zip", "owner", 0L, Map.of())); + var task = service.change(1L, SkillVisibility.PUBLIC, "owner", Map.of(), Set.of()); + assertThat(task.getVisibilityScanTaskId()).isEqualTo("new-scan"); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + assertThatThrownBy(() -> service.approve(task, "admin")).hasMessageContaining("error.skill.visibility.scanIncomplete"); + var old = new SecurityAudit(3L, ScannerType.SKILL_SCANNER, "old-scan"); + old.setScannedAt(java.time.Instant.now()); old.setIsSafe(true); + when(audits.findLatestActiveByVersionIdAndScannerType(3L, ScannerType.SKILL_SCANNER)).thenReturn(Optional.of(old)); + assertThatThrownBy(() -> service.approve(task, "admin")).hasMessageContaining("error.skill.visibility.scanIncomplete"); + var current = new SecurityAudit(3L, ScannerType.SKILL_SCANNER, "new-scan"); + when(audits.findLatestActiveByVersionIdAndScannerType(3L, ScannerType.SKILL_SCANNER)).thenReturn(Optional.of(current)); + assertThatThrownBy(() -> service.approve(task, "admin")).hasMessageContaining("error.skill.visibility.scanIncomplete"); + current.markFailed(java.time.Instant.now(), "scanner unavailable"); + assertThatThrownBy(() -> service.approve(task, "admin")).hasMessageContaining("error.skill.visibility.scanIncomplete"); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + } + + @Test + void missingScanWithDisabledScannerLeavesVisibilityUnchanged() { + skill.setVisibility(SkillVisibility.PRIVATE); + when(audits.findLatestActiveByVersionIdAndScannerType(3L, ScannerType.SKILL_SCANNER)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.change(1L, SkillVisibility.PUBLIC, "owner", Map.of(), Set.of())) + .hasMessageContaining("error.security.scanner.required"); + verify(reviews, never()).save(any()); + assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + } + + @Test + void ordinaryMemberCannotManageOthersSkill() { + assertThatThrownBy(() -> service.change(1L, SkillVisibility.PRIVATE, "stranger", Map.of(2L, NamespaceRole.MEMBER), Set.of())) + .hasMessageContaining("error.skill.lifecycle.noPermission"); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index 5aab079f0..1e9d36e7c 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -27,6 +27,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { this.jpaDelegate = delegate; } + @Override + public Optional findLockedById(Long id) { + return delegate.findLockedById(id); + } + @Override public Optional findById(Long id) { return jpaDelegate.findById(id); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java index 098e12313..10bf8ff55 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.search.event; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.event.SkillVisibilityChangedEvent; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent; import com.iflytek.skillhub.domain.skill.SkillStatus; @@ -33,6 +34,12 @@ public class SearchIndexEventListener { searchRebuildService.rebuildBySkill(event.skillId()); } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("skillhubEventExecutor") + public void onSkillVisibilityChanged(SkillVisibilityChangedEvent event) { + searchRebuildService.rebuildBySkill(event.skillId()); + } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async("skillhubEventExecutor") public void onSkillVersionYanked(SkillVersionYankedEvent event) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java index 2bfee7887..9757da3c8 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java @@ -19,6 +19,15 @@ import static org.mockito.Mockito.verifyNoInteractions; class SearchIndexEventListenerTest { + @Test + void visibilityChangeRebuildsExistingSkill() { + SearchRebuildService rebuild = mock(SearchRebuildService.class); + SearchIndexEventListener listener = new SearchIndexEventListener(rebuild, mock(SearchIndexService.class)); + listener.onSkillVisibilityChanged(new com.iflytek.skillhub.domain.event.SkillVisibilityChangedEvent(42L, + com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC, com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE)); + verify(rebuild).rebuildBySkill(42L); + } + @Test void skillPublishedEventShouldTriggerSkillRebuild() { SearchRebuildService searchRebuildService = mock(SearchRebuildService.class); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2ab2f06f9..08f02cecc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -622,6 +622,19 @@ export const accountApi = { } export const skillLifecycleApi = { + async getVisibility(namespace: string, slug: string) { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson<{ visibility: string; requestedVisibility?: string; reviewTaskId?: number }>( + `${WEB_API_PREFIX}/skills/${encodeURIComponent(cleanNamespace)}/${encodeURIComponent(slug)}/visibility`) + }, + async changeVisibility(namespace: string, slug: string, visibility: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE') { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson<{ visibility: string; requestedVisibility?: string; reviewTaskId?: number }>( + `${WEB_API_PREFIX}/skills/${encodeURIComponent(cleanNamespace)}/${encodeURIComponent(slug)}/visibility`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ visibility }), + }) + }, async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/archive`, { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 59644466b..97dd9f2fb 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -622,6 +622,8 @@ export interface ReviewTask { subjectVersionId?: number | null subjectSlug?: string | null avatarCandidateId?: number | null + previousVisibility?: string + requestedVisibility?: string avatarRemoval?: boolean } @@ -654,6 +656,7 @@ export interface ReviewProgressPage { } export interface ReviewSkillDetail { + visibilityChange?: { previousVisibility: string; requestedVisibility: string; scanStatus: string; readyForApproval: boolean } skill: SkillDetail versions: SkillVersion[] files: SkillFile[] diff --git a/web/src/features/skill/skill-visibility-dialog.test.tsx b/web/src/features/skill/skill-visibility-dialog.test.tsx new file mode 100644 index 000000000..3c7fa5b1a --- /dev/null +++ b/web/src/features/skill/skill-visibility-dialog.test.tsx @@ -0,0 +1,66 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' + +const api = vi.hoisted(() => ({ getVisibility: vi.fn(), changeVisibility: vi.fn() })) +vi.mock('@/api/client', () => ({ skillLifecycleApi: api })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn() } })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/shared/ui/select', () => ({ + Select: ({ value, onValueChange, children, disabled }: { value: string; onValueChange: (value: string) => void; children: ReactNode; disabled?: boolean }) => + , + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: ReactNode }) => , +})) +import { SkillVisibilityDialog } from './skill-visibility-dialog' + +function mount(visibility = 'PUBLIC') { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + fireEvent.click(screen.getByRole('button', { name: 'skillVisibility.title' })) +} + +describe('SkillVisibilityDialog', () => { + afterEach(() => { cleanup(); vi.clearAllMocks() }) + + it('changes only visibility without uploading a new package', async () => { + api.getVisibility.mockResolvedValue({ visibility: 'PUBLIC' }) + api.changeVisibility.mockResolvedValue({ visibility: 'PRIVATE' }) + mount() + await waitFor(() => expect(api.getVisibility).toHaveBeenCalledWith('team', 'foo')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PRIVATE' } }) + await waitFor(() => expect(screen.getByRole('button', { name: 'common.save' }).hasAttribute('disabled')).toBe(false)) + fireEvent.click(screen.getByRole('button', { name: 'common.save' })) + await waitFor(() => expect(api.changeVisibility).toHaveBeenCalledWith('team', 'foo', 'PRIVATE')) + }) + + it('explains that wider access requires review', async () => { + api.getVisibility.mockResolvedValue({ visibility: 'PRIVATE' }) + mount('PRIVATE') + await waitFor(() => expect(api.getVisibility).toHaveBeenCalled()) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PUBLIC' } }) + expect(screen.getByText('skillVisibility.expanding')).toBeTruthy() + expect(screen.getByRole('button', { name: 'skillVisibility.submit' })).toBeTruthy() + }) + + it('reloads a pending request and prevents duplicate submission', async () => { + api.getVisibility.mockResolvedValue({ visibility: 'PRIVATE', requestedVisibility: 'PUBLIC', reviewTaskId: 42 }) + mount('PRIVATE') + await screen.findByRole('status') + expect(screen.getByRole('combobox').hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: 'common.save' }).hasAttribute('disabled')).toBe(true) + expect(api.changeVisibility).not.toHaveBeenCalled() + }) + + it('does not enable saving when current state cannot be read', async () => { + api.getVisibility.mockRejectedValue(new Error('unavailable')) + mount() + await screen.findByRole('alert') + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PRIVATE' } }) + expect(screen.getByRole('button', { name: 'common.save' }).hasAttribute('disabled')).toBe(true) + }) +}) diff --git a/web/src/features/skill/skill-visibility-dialog.tsx b/web/src/features/skill/skill-visibility-dialog.tsx new file mode 100644 index 000000000..2f7c9dd06 --- /dev/null +++ b/web/src/features/skill/skill-visibility-dialog.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { skillLifecycleApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/lib/toast' + +const scopes = ['PUBLIC', 'NAMESPACE_ONLY', 'PRIVATE'] as const +type Visibility = typeof scopes[number] + +export function SkillVisibilityDialog({ namespace, slug, visibility, disabled = false }: { + namespace: string; slug: string; visibility: string; disabled?: boolean +}) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [target, setTarget] = useState('PUBLIC') + const [error, setError] = useState('') + const state = useQuery({ + queryKey: ['skill', namespace, slug, 'visibility'], + queryFn: () => skillLifecycleApi.getVisibility(namespace, slug), + enabled: open, + }) + const currentVisibility = state.data?.visibility ?? visibility + const pendingVisibility = state.data?.requestedVisibility + const mutation = useMutation({ + mutationFn: () => skillLifecycleApi.changeVisibility(namespace, slug, target), + onSuccess: async (result) => { + toast.success(t(result.reviewTaskId ? 'skillVisibility.submitted' : 'skillVisibility.saved')) + setOpen(false) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['skills'] }), + queryClient.invalidateQueries({ queryKey: ['skill'] }), + queryClient.invalidateQueries({ queryKey: ['my-skills'] }), + queryClient.invalidateQueries({ queryKey: ['reviews'] }), + ]) + }, + onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillVisibility.failed')), + }) + const expanding = scopes.indexOf(target) < scopes.indexOf(scopes.find(scope => scope === currentVisibility) ?? 'PUBLIC') + return <> + + { if (!mutation.isPending) setOpen(value) }}> + event.stopPropagation()}> + + {t('skillVisibility.title')} + {t('skillVisibility.description')} + +

{t('skillVisibility.current', { visibility: t(`skillVisibility.${currentVisibility}`) })}

+ {pendingVisibility &&

{t('skillVisibility.pending', { visibility: t(`skillVisibility.${pendingVisibility}`) })}

} + {state.isError &&

{t('skillVisibility.failed')}

} + +

{t(expanding ? 'skillVisibility.expanding' : 'skillVisibility.narrowing')}

+ {error &&

{error}

} + + + + +
+
+ +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0760ee639..d3d5a0347 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2766,5 +2766,23 @@ "archiveHint": "Stop normal access to the space and its skills. Archiving can be reversed.", "restoreHint": "Restore the archived space.", "deleteHint": "Permanently delete the space. Handle its skills and members first." + }, + "skillVisibility": { + "title": "Visibility", + "description": "Change access without changing the skill identity, package version, or statistics.", + "current": "Current scope: {{visibility}}", + "PUBLIC": "Public", + "NAMESPACE_ONLY": "Namespace members", + "PRIVATE": "Only me", + "expanding": "Wider access requires review. Current access stays in effect until approval. Track or withdraw the request in My review progress.", + "narrowing": "Narrower access takes effect immediately. Previous viewers may lose access to details and downloads.", + "submit": "Submit for review", + "submitted": "Visibility change submitted for review", + "saved": "Visibility updated", + "failed": "Update failed", + "scanPENDING": "Security scan in progress. Approval is unavailable until it passes.", + "scanPASSED": "Security scan passed.", + "scanFAILED": "Security scan did not pass. Current visibility is unchanged. Withdraw and retry or upload a fixed version.", + "pending": "Pending visibility request: {{visibility}}. Complete or withdraw it in My review progress." } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index da1bde32c..1bab0e5b5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2768,5 +2768,23 @@ "archiveHint": "Остановить доступ к пространству и навыкам. Архивацию можно отменить.", "restoreHint": "Восстановить архивированное пространство.", "deleteHint": "Удалить пространство навсегда. Сначала обработайте его навыки и участников." + }, + "skillVisibility": { + "title": "Видимость", + "description": "Изменение доступа без изменения идентификатора навыка, версии пакета и статистики.", + "current": "Текущий доступ: {{visibility}}", + "PUBLIC": "Публичный", + "NAMESPACE_ONLY": "Участникам пространства", + "PRIVATE": "Только мне", + "expanding": "Расширение доступа требует проверки. До одобрения действует текущий доступ. Заявку можно просмотреть или отозвать в разделе проверок.", + "narrowing": "Ограничение доступа применяется сразу. Предыдущие пользователи могут потерять доступ к просмотру и загрузке.", + "submit": "Отправить на проверку", + "submitted": "Заявка на изменение видимости отправлена", + "saved": "Видимость обновлена", + "failed": "Ошибка обновления", + "scanPENDING": "Проверка безопасности выполняется. Одобрение доступно после успешной проверки.", + "scanPASSED": "Проверка безопасности пройдена.", + "scanFAILED": "Проверка безопасности не пройдена. Доступ не изменён. Отзовите заявку и повторите попытку или загрузите исправленную версию.", + "pending": "Ожидающая заявка: {{visibility}}. Завершите или отзовите её в разделе проверок." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0f429519a..d6af40231 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2765,5 +2765,23 @@ "archiveHint": "归档后停止空间及其中技能的正常访问。可恢复归档。", "restoreHint": "恢复已归档空间。", "deleteHint": "永久删除空间,请先处理其中的技能和成员。" + }, + "skillVisibility": { + "title": "可见范围", + "description": "仅调整访问范围,保留技能标识、内容版本和统计。", + "current": "当前范围:{{visibility}}", + "PUBLIC": "公开", + "NAMESPACE_ONLY": "空间成员可见", + "PRIVATE": "仅自己可见", + "expanding": "扩大范围需要审核,通过前保持当前范围。可在“我的审核进度”查看或撤回申请。", + "narrowing": "缩小范围立即生效,原先可访问的用户可能无法继续查看或下载。", + "submit": "提交审核", + "submitted": "已提交可见范围审核", + "saved": "可见范围已更新", + "failed": "更新失败", + "scanPENDING": "安全扫描中,完成前不能通过审核。", + "scanPASSED": "安全扫描已通过。", + "scanFAILED": "安全扫描未通过,当前范围保持不变。请撤回后重试或上传修复版本。", + "pending": "已有待审核范围申请:{{visibility}}。请到“我的审核进度”完成或撤回。" } } diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 9525ec5be..ae0b6d800 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -18,6 +18,7 @@ import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPen import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' import { buildReturnTo } from '@/shared/lib/auth-route' +import { SkillVisibilityDialog } from '@/features/skill/skill-visibility-dialog' import { SkillAvatar } from '@/features/skill/skill-avatar' import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' import { ApiError } from '@/api/client' @@ -445,6 +446,10 @@ export function MySkillsPage() { {t('mySkills.update')} )} + {filter !== 'HIDDEN' && skill.status === 'ACTIVE' && publishedVersion && ( + + )} {filter === 'HIDDEN' ? null : hasPendingPreview && ownerPreviewVersion ? (