mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-15 09:11:18 +00:00
feat(skill): review independent published visibility changes
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
4c1c43767a
commit
980fa29d81
38 changed files with 818 additions and 10 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- 扩展现有 SKILL_METADATA ReviewTask,为范围申请保存原范围、目标范围、绑定已发布版本;不新增独立审核队列。
|
||||
- 技能行锁协调提交、发布和审核;范围申请不修改 SkillVersion 状态和 latestVersionId。
|
||||
- 审批 CAS 保留现有冲突机制。成功改变范围后通过现有发布投影事件刷新搜索;审计区分提交、通过、拒绝、撤回。
|
||||
- 审批 CAS 保留现有冲突机制。成功改变范围后通过专用范围变更事件复用现有搜索重建,避免重复发送内容更新通知;审计区分提交、通过、拒绝、撤回。
|
||||
- Web 复用管理交互与统一审核详情;展示当前范围和申请范围。
|
||||
- 业务约束在服务代码与测试维护,迁移只增加必要列,不增加 SQL CHECK。
|
||||
- 安全扫描前置条件需要先核对当前已发布私有版本行为;不能默默扩大范围绕过扫描。
|
||||
- 已发布 PRIVATE 版本没有可复用的安全通过结果时,复用 ScanTask/outbox 对已存 bundle 重新扫描,保留 PUBLISHED 状态。范围申请绑定这一次 taskId;旧结果不能解锁新申请,未完成或不安全结果不能批准。scanner 关闭且需要新扫描时明确拒绝,不改变原范围。
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
- **THEN** 请求被拒绝,现有申请和内容不变
|
||||
|
||||
### Requirement: 扩大范围 SHALL 不绕过安全和头像审核
|
||||
沿用当前技能内容审核规则及目标受众头像规则。现有 PRIVATE 发布可能未执行安全扫描;实现 SHALL 核对扩大范围的扫描前置条件,不得通过独立范围接口绕过现有公开分发安全要求。
|
||||
沿用当前技能内容审核规则及目标受众头像规则。现有 PRIVATE 发布可能未执行安全扫描;实现 SHALL 核对扩大范围的扫描前置条件,不得通过独立范围接口绕过现有公开分发安全要求。没有安全通过结果时 SHALL 直接扫描已存发布包而不改变已发布状态;范围申请 SHALL 绑定扫描 taskId。未完成、失败、不安全或旧扫描结果 SHALL 不能批准;原范围保持有效。需要新扫描但 scanner 未启用时 SHALL 明确拒绝。
|
||||
|
||||
#### Scenario: 审核期间发布版本改变
|
||||
- **WHEN** 范围申请绑定的发布版本或原范围与当前技能不符
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=Текущая проверка безопасности не пройдена. Расширение доступа нельзя одобрить.
|
||||
|
|
|
|||
|
|
@ -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=本次安全扫描尚未通过,无法批准扩大范围。
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
66
web/src/features/skill/skill-visibility-dialog.test.tsx
Normal file
66
web/src/features/skill/skill-visibility-dialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
75
web/src/features/skill/skill-visibility-dialog.tsx
Normal file
75
web/src/features/skill/skill-visibility-dialog.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}. Завершите или отзовите её в разделе проверок."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}。请到“我的审核进度”完成或撤回。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue