feat(skill): review independent published visibility changes

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-15 19:28:36 +08:00
parent 4c1c43767a
commit 980fa29d81
38 changed files with 818 additions and 10 deletions

View file

@ -2,7 +2,7 @@
- 扩展现有 SKILL_METADATA ReviewTask,为范围申请保存原范围、目标范围、绑定已发布版本;不新增独立审核队列。
- 技能行锁协调提交、发布和审核;范围申请不修改 SkillVersion 状态和 latestVersionId。
- 审批 CAS 保留现有冲突机制。成功改变范围后通过现有发布投影事件刷新搜索;审计区分提交、通过、拒绝、撤回。
- 审批 CAS 保留现有冲突机制。成功改变范围后通过专用范围变更事件复用现有搜索重建,避免重复发送内容更新通知;审计区分提交、通过、拒绝、撤回。
- Web 复用管理交互与统一审核详情;展示当前范围和申请范围。
- 业务约束在服务代码与测试维护,迁移只增加必要列,不增加 SQL CHECK。
- 安全扫描前置条件需要先核对当前已发布私有版本行为;不能默默扩大范围绕过扫描。
- 已发布 PRIVATE 版本没有可复用的安全通过结果时,复用 ScanTask/outbox 对已存 bundle 重新扫描,保留 PUBLISHED 状态。范围申请绑定这一次 taskId;旧结果不能解锁新申请,未完成或不安全结果不能批准。scanner 关闭且需要新扫描时明确拒绝,不改变原范围。

View file

@ -22,7 +22,7 @@
- **THEN** 请求被拒绝,现有申请和内容不变
### Requirement: 扩大范围 SHALL 不绕过安全和头像审核
沿用当前技能内容审核规则及目标受众头像规则。现有 PRIVATE 发布可能未执行安全扫描;实现 SHALL 核对扩大范围的扫描前置条件,不得通过独立范围接口绕过现有公开分发安全要求。
沿用当前技能内容审核规则及目标受众头像规则。现有 PRIVATE 发布可能未执行安全扫描;实现 SHALL 核对扩大范围的扫描前置条件,不得通过独立范围接口绕过现有公开分发安全要求。没有安全通过结果时 SHALL 直接扫描已存发布包而不改变已发布状态;范围申请 SHALL 绑定扫描 taskId。未完成、失败、不安全或旧扫描结果 SHALL 不能批准;原范围保持有效。需要新扫描但 scanner 未启用时 SHALL 明确拒绝。
#### Scenario: 审核期间发布版本改变
- **WHEN** 范围申请绑定的发布版本或原范围与当前技能不符

View file

@ -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<SkillVisibilityAppService.Result> current(@PathVariable String namespace,
@PathVariable String slug, @RequestAttribute("userId") String actor,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
return ok("response.success", service.current(namespace, slug, actor, roles));
}
@PostMapping("/{namespace}/{slug}/visibility")
public ApiResponse<SkillVisibilityAppService.Result> change(@PathVariable String namespace,
@PathVariable String slug, @Valid @RequestBody ChangeVisibilityRequest request,
@RequestAttribute("userId") String actor,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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) {}
}

View file

@ -10,8 +10,16 @@ public record ReviewSkillDetailResponse(
String documentationContent,
String downloadUrl,
String activeVersion,
AvatarChange avatarChange
AvatarChange avatarChange,
VisibilityChange visibilityChange
) {
public ReviewSkillDetailResponse(SkillDetailResponse skill, List<SkillVersionResponse> versions,
List<SkillFileResponse> 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<SkillVersionResponse> versions,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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=Текущая проверка безопасности не пройдена. Расширение доступа нельзя одобрить.

View file

@ -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=本次安全扫描尚未通过,无法批准扩大范围。

View file

@ -813,6 +813,15 @@ class ScanTaskConsumerTest {
}
private static final class InMemoryReviewTaskRepository implements ReviewTaskRepository {
@Override
public Optional<ReviewTask> 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;

View file

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

View file

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

View file

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

View file

@ -11,6 +11,8 @@ import java.util.Optional;
*/
public interface ReviewTaskRepository {
default List<ReviewTask> findByAvatarCandidateIdAndStatus(Long candidateId, ReviewTaskStatus status) { return List.of(); }
Optional<ReviewTask> findBySkillIdAndRequestedVisibilityIsNotNullAndStatus(Long skillId, ReviewTaskStatus status);
boolean existsBySkillIdAndRequestedVisibilityIsNotNullAndStatus(Long skillId, ReviewTaskStatus status);
ReviewTask save(ReviewTask reviewTask);
Optional<ReviewTask> findById(Long id);
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);

View file

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

View file

@ -11,6 +11,7 @@ import java.util.Optional;
*/
public interface SkillRepository {
Optional<Skill> findById(Long id);
Optional<Skill> findLockedById(Long id);
List<Skill> findByIdIn(List<Long> ids);
List<Skill> findAll();
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);

View file

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

View file

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

View file

@ -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<Long, NamespaceRole> roles, Set<String> 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<Long, NamespaceRole> roles, Set<String> 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<Long, NamespaceRole> roles, Set<String> 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));
}
}

View file

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

View file

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

View file

@ -27,6 +27,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
this.jpaDelegate = delegate;
}
@Override
public Optional<Skill> findLockedById(Long id) {
return delegate.findLockedById(id);
}
@Override
public Optional<Skill> findById(Long id) {
return jpaDelegate.findById(id);

View file

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

View file

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

View file

@ -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<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/archive`, {

View file

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

View file

@ -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 }) =>
<select value={value} onChange={(event) => onValueChange(event.target.value)} disabled={disabled}>{children}</select>,
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => <option value={value}>{children}</option>,
}))
import { SkillVisibilityDialog } from './skill-visibility-dialog'
function mount(visibility = 'PUBLIC') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SkillVisibilityDialog namespace="team" slug="foo" visibility={visibility} /></QueryClientProvider>)
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)
})
})

View file

@ -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<Visibility>('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 <>
<Button size="sm" variant="outline" disabled={disabled} onClick={(event) => {
event.stopPropagation()
setTarget(scopes.find(scope => scope === visibility) ?? 'PUBLIC')
setError('')
setOpen(true)
}}>{t('skillVisibility.title')}</Button>
<Dialog open={open} onOpenChange={(value) => { if (!mutation.isPending) setOpen(value) }}>
<DialogContent onClick={(event) => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t('skillVisibility.title')}</DialogTitle>
<DialogDescription>{t('skillVisibility.description')}</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('skillVisibility.current', { visibility: t(`skillVisibility.${currentVisibility}`) })}</p>
{pendingVisibility && <p role="status" className="text-sm">{t('skillVisibility.pending', { visibility: t(`skillVisibility.${pendingVisibility}`) })}</p>}
{state.isError && <p role="alert" className="text-sm text-destructive">{t('skillVisibility.failed')}</p>}
<Select value={target} onValueChange={(value) => {
const scope = scopes.find(item => item === value)
if (scope) setTarget(scope)
}} disabled={mutation.isPending || Boolean(pendingVisibility)}>
<SelectTrigger aria-label={t('skillVisibility.title')}><SelectValue /></SelectTrigger>
<SelectContent>{scopes.map(scope => <SelectItem key={scope} value={scope}>{t(`skillVisibility.${scope}`)}</SelectItem>)}</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">{t(expanding ? 'skillVisibility.expanding' : 'skillVisibility.narrowing')}</p>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" disabled={mutation.isPending} onClick={() => setOpen(false)}>{t('common.cancel')}</Button>
<Button disabled={mutation.isPending || state.isLoading || state.isError || Boolean(pendingVisibility) || target === currentVisibility} onClick={() => mutation.mutate()}>{t(expanding ? 'skillVisibility.submit' : 'common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}

View file

@ -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."
}
}

View file

@ -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}}. Завершите или отзовите её в разделе проверок."
}
}

View file

@ -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}}。请到“我的审核进度”完成或撤回。"
}
}

View file

@ -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')}
</Button>
)}
{filter !== 'HIDDEN' && skill.status === 'ACTIVE' && publishedVersion && (
<SkillVisibilityDialog namespace={skill.namespace} slug={skill.slug}
visibility={skill.visibility ?? 'PUBLIC'} disabled={hasPendingPreview} />
)}
{filter === 'HIDDEN' ? null : hasPendingPreview && ownerPreviewVersion ? (
<Button
size="sm"

View file

@ -79,7 +79,7 @@ function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug:
<div className="flex items-center justify-between gap-4">
<div>
<div className="font-semibold font-heading">{review.namespace}/{review.skillSlug}</div>
<div className="text-sm text-muted-foreground">{review.subjectType === 'SKILL_METADATA' ? t(review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : t('nsReviews.version', { version: review.version })}</div>
<div className="text-sm text-muted-foreground">{review.subjectType === 'SKILL_METADATA' ? t(review.requestedVisibility ? 'skillVisibility.title' : review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : t('nsReviews.version', { version: review.version })}</div>
</div>
<div className="text-sm text-muted-foreground">
{formatLocalDateTime(

View file

@ -203,7 +203,7 @@ function ReviewDetailScreen({
(version) => version.version === reviewSkillDetail.activeVersion
)
const isApprovalBlockedByScanning = !isMetadataReview && activeReviewVersion?.status === 'SCANNING'
const isApprovalBlockedByAvatar = reviewSkillDetail?.avatarChange?.readyForApproval === false
const isApprovalBlockedByAvatar = reviewSkillDetail?.avatarChange?.readyForApproval === false || reviewSkillDetail?.visibilityChange?.readyForApproval === false
const isAvatarOverrideReasonRequired = reviewSkillDetail?.avatarChange?.machineStatus === 'BLOCKED' && !comment.trim()
const isApprovalBlocked = isApprovalBlockedByScanning || isApprovalBlockedByAvatar || isAvatarOverrideReasonRequired
|| (!isSuiteReview && (isLoadingReviewSkillDetail || !!reviewSkillDetailError || !reviewSkillDetail))
@ -298,6 +298,12 @@ function ReviewDetailScreen({
)}
</Card>
{review.requestedVisibility && <section className="rounded-xl border p-5 space-y-2">
<h2 className="font-semibold">{t('skillVisibility.title')}</h2>
<p>{t(`skillVisibility.${review.previousVisibility}`)} → {t(`skillVisibility.${review.requestedVisibility}`)}</p>
{reviewSkillDetail?.visibilityChange && <p>{t(`skillVisibility.scan${reviewSkillDetail.visibilityChange.scanStatus}`)}</p>}
<p className="text-sm text-muted-foreground">{t('skillVisibility.expanding')}</p>
</section>}
{reviewSkillDetail?.avatarChange && <ReviewAvatarChangeSection change={reviewSkillDetail.avatarChange} name={reviewSkillDetail.skill.displayName || review.skillSlug || ''} />}
{review.status === 'PENDING' && (

View file

@ -164,7 +164,7 @@ export function ReviewsPage() {
</span>
{review.namespace}/{review.subjectSlug || review.skillSlug}
</TableCell>
<TableCell>{review.subjectType === 'SKILL_METADATA' ? t(review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : review.version}</TableCell>
<TableCell>{review.subjectType === 'SKILL_METADATA' ? t(review.requestedVisibility ? 'skillVisibility.title' : review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : review.version}</TableCell>
<TableCell>{review.submittedByName || review.submittedBy}</TableCell>
{status === 'PENDING' ? (
<TableCell>{formatDate(review.submittedAt)}</TableCell>