feat: 完善 Skill 管理与发布治理

- 实现标准资源存储、能力绑定及双格式导入导出

- 接入分类、可见范围、审批发布与资源权限校验

- 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
2026-07-27 18:54:20 +08:00
parent aedefe6b5e
commit 2a9e882ac6
165 changed files with 23737 additions and 1088 deletions

View File

@@ -0,0 +1,230 @@
package tech.easyflow.skill.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.ArgumentCaptor;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SkillApprovalSubjectHandler} 发布候选与已发布快照引用所有权测试。
*/
public class SkillApprovalSubjectHandlerContentReferenceTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private static final BigInteger OPERATOR_ID = BigInteger.valueOf(7);
private ApprovalInstanceService approvalInstanceService;
private SkillService skillService;
private SkillMapper skillMapper;
private SkillApprovalSubjectHandler handler;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化审批处理器。
*/
@Before
public void setUp() {
approvalInstanceService = mock(ApprovalInstanceService.class);
skillService = mock(SkillService.class);
skillMapper = mock(SkillMapper.class);
LoginAccount account = new LoginAccount();
account.setId(OPERATOR_ID);
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
when(skillMapper.updateApprovalState(any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publish(any(), any(), any(), any(), any(), any())).thenReturn(1);
handler = new SkillApprovalSubjectHandler(
approvalInstanceService,
new ObjectMapper(),
skillService,
skillMapper,
mock(ResourceAccessService.class));
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证发布候选在提交审批请求时立即持有自己的内容引用。
*/
@Test
public void publishCandidateRetainsSnapshotContentsOnSubmit() {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> candidate = snapshot("sha256:candidate");
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
when(skillService.buildPublishSnapshot(draft)).thenReturn(candidate);
ApprovalSubmitRequest request = handler.buildSubmitRequest(
SKILL_ID, ApprovalActionType.PUBLISH.getCode(), OPERATOR_ID);
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(skillMapper).selectOneByQuery(queryCaptor.capture());
assertTrue(queryCaptor.getValue().toSQL().toLowerCase().contains("for update"));
verify(skillService).retainSnapshotContents(candidate);
assertSame(candidate, request.getSnapshotJson().get("resourceSnapshot"));
assertEquals(PublishStatus.DRAFT.getCode(), request.getSnapshotJson().get("previousPublishStatus"));
}
/**
* 验证重新发布时候选引用转为已发布持有,只释放被替换的旧快照。
*/
@Test
public void approvedRepublishReleasesOnlyPreviousPublishedSnapshot() {
Map<String, Object> previous = snapshot("sha256:previous");
Map<String, Object> candidate = snapshot("sha256:candidate");
Skill published = skill(PublishStatus.PUBLISHED, previous);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID);
verify(skillMapper).publish(
eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), any(Date.class),
eq(OPERATOR_ID), isNull());
verify(skillService).releaseSnapshotContents(previous);
verify(skillService, never()).releaseSnapshotContents(candidate);
}
/**
* 验证发布审批驳回或撤回会释放候选快照,且不会释放当前线上快照。
*/
@Test
public void rejectedPublishReleasesCandidateButKeepsPublishedSnapshot() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> publishedSnapshot = snapshot("sha256:published");
Map<String, Object> candidate = snapshot("sha256:candidate");
Skill published = skill(PublishStatus.PUBLISH_PENDING, publishedSnapshot);
published.setCurrentApprovalInstanceId(instanceId);
ApprovalInstance instance = new ApprovalInstance();
instance.setActionType(ApprovalActionType.PUBLISH.getCode());
instance.setSnapshotJson(Map.of("resourceSnapshot", candidate));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
when(approvalInstanceService.getById(instanceId)).thenReturn(instance);
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED);
verify(skillMapper).updateApprovalState(
SKILL_ID, BigInteger.ONE, PublishStatus.PUBLISHED.getCode(), null);
verify(skillService).releaseSnapshotContents(candidate);
verify(skillService, never()).releaseSnapshotContents(publishedSnapshot);
}
/**
* 验证删除草稿不触发发布级校验,并使用不含凭据的治理快照。
*/
@Test
public void deleteDraftUsesGovernanceSnapshotWithoutPublishValidation() {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> governance = Map.of(
"id", SKILL_ID,
"name", "demo-skill",
"capabilityCount", 1);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
when(skillService.buildGovernanceSnapshot(draft)).thenReturn(governance);
ApprovalSubmitRequest request = handler.buildSubmitRequest(
SKILL_ID, ApprovalActionType.DELETE.getCode(), OPERATOR_ID);
assertSame(governance, request.getSnapshotJson().get("resourceSnapshot"));
verify(skillService).buildGovernanceSnapshot(draft);
verify(skillService, never()).buildPublishSnapshot(any());
verify(skillService, never()).retainSnapshotContents(any());
}
/**
* 验证已发布 Skill 仍须先下线,且不会构建任何删除快照。
*/
@Test
public void deletePublishedSkillRequiresOfflineFirst() {
Skill published = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
assertThrows(BusinessException.class, () -> handler.buildSubmitRequest(
SKILL_ID, ApprovalActionType.DELETE.getCode(), OPERATOR_ID));
verify(skillService, never()).buildGovernanceSnapshot(any());
verify(skillService, never()).buildPublishSnapshot(any());
}
/**
* 删除审批通过或无审批直通时必须使用生命周期专用聚合删除入口。
*/
@Test
public void approvedDeleteUsesLifecycleAggregateRemoval() {
handler.applyApprovedAction(
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID);
verify(skillService).removeLifecycleAggregate(SKILL_ID);
verify(skillService, never()).removeAggregate(SKILL_ID);
}
/**
* 创建指定生命周期状态的 Skill。
*
* @param status 发布状态
* @param publishedSnapshot 已发布快照
* @return Skill
*/
private Skill skill(PublishStatus status, Map<String, Object> publishedSnapshot) {
Skill skill = new Skill();
skill.setId(SKILL_ID);
skill.setTenantId(BigInteger.ONE);
skill.setName("demo-skill");
skill.setDisplayName("Demo Skill");
skill.setPublishStatus(status.getCode());
skill.setPublishedSnapshotJson(publishedSnapshot);
return skill;
}
/**
* 创建单二进制资源快照。
*
* @param contentRef 内容引用
* @return 快照
*/
private Map<String, Object> snapshot(String contentRef) {
return Map.of("resources", List.of(Map.of(
"path", "assets/file.bin",
"contentRef", contentRef)));
}
}