feat: 重构标准 Skill 管理与发布链路
- 统一标准 ZIP 导入导出与通用资源模型 - 收口分类范围权限和创建人查询 - 完善发布快照、审批幂等与数据库清理迁移
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
"首次发布");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user