feat: 重构标准 Skill 管理与发布链路

- 统一标准 ZIP 导入导出与通用资源模型

- 收口分类范围权限和创建人查询

- 完善发布快照、审批幂等与数据库清理迁移
This commit is contained in:
2026-08-14 18:53:26 +08:00
parent 9be9bd7665
commit 77d66e1b42
91 changed files with 1660 additions and 10957 deletions

View File

@@ -11,6 +11,8 @@ 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.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -68,6 +70,9 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
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);
when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.markOfflineApproved(any(), any(), any())).thenReturn(1);
when(skillMapper.restoreApprovalState(any(), any(), any(), any())).thenReturn(1);
handler = new SkillApprovalSubjectHandler(
approvalInstanceService,
new ObjectMapper(),
@@ -85,10 +90,10 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
}
/**
* 验证发布候选在提交审批请求时立即持有自己的内容引用。
* 验证只构建审批请求不会持有内容引用,避免预检产生副作用。
*/
@Test
public void publishCandidateRetainsSnapshotContentsOnSubmit() {
public void buildPublishRequestDoesNotRetainSnapshotContents() {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> candidate = snapshot("sha256:candidate");
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
@@ -100,7 +105,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(skillMapper).selectOneByQuery(queryCaptor.capture());
assertTrue(queryCaptor.getValue().toSQL().toLowerCase().contains("for update"));
verify(skillService).retainSnapshotContents(candidate);
verify(skillService, never()).retainSnapshotContents(candidate);
assertSame(candidate, request.getSnapshotJson().get("resourceSnapshot"));
assertEquals(PublishStatus.DRAFT.getCode(), request.getSnapshotJson().get("previousPublishStatus"));
}
@@ -157,8 +162,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> governance = Map.of(
"id", SKILL_ID,
"name", "demo-skill",
"capabilityCount", 1);
"name", "demo-skill");
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
when(skillService.buildGovernanceSnapshot(draft)).thenReturn(governance);
@@ -198,6 +202,160 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
verify(skillService, never()).removeAggregate(SKILL_ID);
}
/**
* 审批发布仅允许当前审批实例写入冻结快照。
*/
@Test
public void approvedPublishUsesApprovalInstanceCompareAndSet() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> candidate = Map.of("snapshotHash", "candidate-hash");
Skill pending = skill(PublishStatus.PUBLISH_PENDING, Map.of());
pending.setCurrentApprovalInstanceId(instanceId);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(),
SKILL_ID,
candidate,
OPERATOR_ID,
instanceId);
verify(skillService).assertSnapshotHash(candidate);
verify(skillMapper).publishApproved(
eq(SKILL_ID),
eq(BigInteger.ONE),
eq(instanceId),
same(candidate),
any(Date.class),
eq(OPERATOR_ID),
eq("candidate-hash"));
}
/**
* 过期审批实例不得覆盖新的 Skill 状态。
*/
@Test
public void staleApprovalCallbackIsRejected() {
Skill pending = skill(PublishStatus.PUBLISH_PENDING, Map.of());
pending.setCurrentApprovalInstanceId(BigInteger.valueOf(100));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending);
when(approvalInstanceService.getById(BigInteger.valueOf(99)))
.thenReturn(approvalInstance(
BigInteger.valueOf(99), ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
BusinessException exception = assertThrows(BusinessException.class, () -> handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(),
SKILL_ID,
Map.of("snapshotHash", "candidate-hash"),
OPERATOR_ID,
BigInteger.valueOf(99)));
assertEquals(409, exception.getHttpStatus());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
}
/**
* 同一发布申请的重复通过回调应幂等成功且不重复释放内容。
*/
@Test
public void repeatedPublishApprovalIsNoOp() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> candidate = Map.of("snapshotHash", "candidate-hash");
Skill published = skill(PublishStatus.PUBLISHED, candidate);
published.setSnapshotHash("candidate-hash");
published.setCurrentApprovalInstanceId(instanceId);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID, instanceId);
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
verify(skillService, never()).releaseSnapshotContents(any());
}
/**
* 同一下线申请的重复通过回调应幂等成功。
*/
@Test
public void repeatedOfflineApprovalIsNoOp() {
BigInteger instanceId = BigInteger.valueOf(99);
Skill offline = skill(PublishStatus.OFFLINE, snapshot("sha256:published"));
offline.setCurrentApprovalInstanceId(instanceId);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(offline);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.OFFLINE, ApprovalInstanceStatus.APPROVED));
handler.applyApprovedAction(
ApprovalActionType.OFFLINE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId);
verify(skillMapper, never()).markOfflineApproved(any(), any(), any());
}
/**
* 同一删除申请在资源已经删除后重复回调应幂等成功。
*/
@Test
public void repeatedDeleteApprovalForMissingSkillIsNoOp() {
BigInteger instanceId = BigInteger.valueOf(99);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.DELETE, ApprovalInstanceStatus.APPROVED));
when(approvalInstanceService.isLatestResourceInstance(
instanceId, ApprovalResourceType.SKILL.getCode(), SKILL_ID)).thenReturn(true);
handler.applyApprovedAction(
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId);
verify(skillService, never()).removeLifecycleAggregate(any());
}
/**
* 较旧删除申请不能把资源缺失误判为自身已完成。
*/
@Test
public void staleDeleteApprovalForMissingSkillIsRejected() {
BigInteger instanceId = BigInteger.valueOf(99);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.DELETE, ApprovalInstanceStatus.APPROVED));
BusinessException exception = assertThrows(BusinessException.class, () -> handler.applyApprovedAction(
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId));
assertEquals(409, exception.getHttpStatus());
verify(skillService, never()).removeLifecycleAggregate(any());
}
/**
* 驳回回调首次释放候选引用,之后同一实例重放保持无副作用。
*/
@Test
public void repeatedRejectRestoreIsNoOpAfterFirstApplication() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> candidate = snapshot("sha256:candidate");
Skill pending = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
pending.setCurrentApprovalInstanceId(instanceId);
Skill restored = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
ApprovalInstance instance = approvalInstance(
instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.REJECTED);
instance.setSnapshotJson(Map.of("resourceSnapshot", candidate));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending, restored);
when(approvalInstanceService.getById(instanceId)).thenReturn(instance);
when(approvalInstanceService.isLatestResourceInstance(
instanceId, ApprovalResourceType.SKILL.getCode(), SKILL_ID)).thenReturn(true);
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED, instanceId);
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED, instanceId);
verify(skillService).releaseSnapshotContents(candidate);
verify(skillMapper).restoreApprovalState(
SKILL_ID, BigInteger.ONE, instanceId, PublishStatus.PUBLISHED.getCode());
}
/**
* 创建指定生命周期状态的 Skill。
*
@@ -227,4 +385,24 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
"path", "assets/file.bin",
"contentRef", contentRef)));
}
/**
* 创建与当前 Skill 回调匹配的审批实例。
*
* @param instanceId 实例 ID
* @param action 动作
* @param status 实例状态
* @return 审批实例
*/
private ApprovalInstance approvalInstance(BigInteger instanceId,
ApprovalActionType action,
ApprovalInstanceStatus status) {
ApprovalInstance instance = new ApprovalInstance();
instance.setId(instanceId);
instance.setResourceType(ApprovalResourceType.SKILL.getCode());
instance.setResourceId(SKILL_ID);
instance.setActionType(action.getCode());
instance.setStatus(status.getCode());
return instance;
}
}

View File

@@ -0,0 +1,74 @@
package tech.easyflow.skill.publish;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.ai.publish.AiResourceLifecycleService;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
/**
* {@link SkillPublishAppService} 发布说明契约测试。
*/
public class SkillPublishAppServiceTest {
/**
* 发布说明必须包含可见字符。
*/
@Test
public void rejectsBlankPublishReason() {
SkillPublishAppService service = new SkillPublishAppService(mock(AiResourceLifecycleService.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.submitPublishApproval(BigInteger.ONE, " \n "));
assertEquals("发布说明不能为空", exception.getMessage());
}
/**
* 发布说明最长为 500 个字符。
*/
@Test
public void rejectsPublishReasonLongerThanLimit() {
SkillPublishAppService service = new SkillPublishAppService(mock(AiResourceLifecycleService.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.submitPublishApproval(BigInteger.ONE, "a".repeat(501)));
assertEquals("发布说明不能超过 500 个字符", exception.getMessage());
}
/**
* 提交发布时会规范化说明并透传登录身份。
*/
@Test
public void trimsAndForwardsPublishReason() {
AiResourceLifecycleService lifecycleService = mock(AiResourceLifecycleService.class);
SkillPublishAppService service = new SkillPublishAppService(lifecycleService);
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
service.submitPublishApproval(BigInteger.valueOf(101), " 首次发布 ");
}
verify(lifecycleService).submitAction(
ApprovalResourceType.SKILL.getCode(),
BigInteger.valueOf(101),
ApprovalActionType.PUBLISH.getCode(),
BigInteger.valueOf(7),
"首次发布");
}
}