diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java index 8648e01b..6ca5942e 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java @@ -2,18 +2,29 @@ package tech.easyflow.admin.controller.job; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.hutool.core.date.DateUtil; +import com.easyagents.flow.core.chain.Parameter; import org.quartz.CronExpression; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.job.entity.SysJob; +import tech.easyflow.job.job.JobConstant; import tech.easyflow.job.service.SysJobService; +import tech.easyflow.job.support.SysJobWorkflowReferenceSupport; import tech.easyflow.log.annotation.LogRecord; - -import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; import java.io.Serializable; import java.math.BigInteger; @@ -21,6 +32,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.List; +import java.util.Map; /** * 系统任务表 控制层。 @@ -31,8 +43,32 @@ import java.util.List; @RestController @RequestMapping("/api/v1/sysJob") public class SysJobController extends BaseCurdController { - public SysJobController(SysJobService service) { + + /** 工作流服务。 */ + private final WorkflowService workflowService; + + /** 资源访问控制服务。 */ + private final ResourceAccessService resourceAccessService; + + /** 工作流运行参数解析器。 */ + private final WorkflowRunningParameterResolver workflowRunningParameterResolver; + + /** + * 创建定时任务控制器。 + * + * @param service 定时任务服务 + * @param workflowService 工作流服务 + * @param resourceAccessService 资源访问控制服务 + * @param workflowRunningParameterResolver 工作流运行参数解析器 + */ + public SysJobController(SysJobService service, + WorkflowService workflowService, + ResourceAccessService resourceAccessService, + WorkflowRunningParameterResolver workflowRunningParameterResolver) { super(service); + this.workflowService = workflowService; + this.resourceAccessService = resourceAccessService; + this.workflowRunningParameterResolver = workflowRunningParameterResolver; } @GetMapping("/start") @@ -73,9 +109,94 @@ public class SysJobController extends BaseCurdController entity.setModified(new Date()); entity.setModifiedBy(loginUser.getId()); } + validateWorkflowReference(entity); return super.onSaveOrUpdateBefore(entity, isSave); } + /** + * 校验工作流类型任务引用的工作流可被当前用户运行。 + * + * @param entity 待保存的定时任务 + * @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出 + */ + private void validateWorkflowReference(SysJob entity) { + if (entity == null + || !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) { + return; + } + BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity); + Workflow workflow = workflowService.getById(workflowId); + if (workflow == null) { + throw new BusinessException("工作流不存在,请重新选择"); + } + resourceAccessService.assertAccess( + CategoryResourceType.WORKFLOW, + workflow, + ResourceAction.USE, + "无权限运行所选工作流" + ); + validateRequiredWorkflowParams(entity, workflow); + } + + /** + * 校验定时任务已填写工作流的全部必填运行参数。 + * + * @param entity 待保存定时任务 + * @param workflow 关联工作流 + * @throws BusinessException 工作流参数配置无效或必填值缺失时抛出 + */ + private void validateRequiredWorkflowParams(SysJob entity, Workflow workflow) { + List parameters = + workflowRunningParameterResolver.resolveStartParameters(workflow.getContent()); + if (parameters == null) { + throw new BusinessException("工作流参数配置无效,请检查工作流后重试"); + } + Map jobParams = entity.getJobParams(); + Object rawWorkflowParams = jobParams == null + ? null + : jobParams.get(JobConstant.WORKFLOW_PARAMS_KEY); + Map workflowParams = rawWorkflowParams instanceof Map map + ? map + : Map.of(); + for (Parameter parameter : parameters) { + if (parameter == null || !parameter.isRequired()) { + continue; + } + String name = parameter.getName(); + if (!StringUtils.hasText(name)) { + throw new BusinessException("工作流存在无效必填参数配置,请检查工作流后重试"); + } + if (!hasRequiredValue(workflowParams.get(name))) { + String label = StringUtils.hasText(parameter.getFormLabel()) + ? parameter.getFormLabel() + : name; + throw new BusinessException("工作流必填参数“" + label + "”不能为空"); + } + } + } + + /** + * 判断必填参数值是否有效。 + * + * @param value 参数值 + * @return 非空时为 true + */ + private boolean hasRequiredValue(Object value) { + if (value == null) { + return false; + } + if (value instanceof CharSequence sequence) { + return StringUtils.hasText(sequence); + } + if (value instanceof Collection collection) { + return !collection.isEmpty(); + } + if (value instanceof Map map) { + return !map.isEmpty(); + } + return true; + } + @Override protected Result onRemoveBefore(Collection ids) { service.deleteJob(ids); diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java new file mode 100644 index 00000000..89520e75 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java @@ -0,0 +1,79 @@ +package tech.easyflow.admin.controller.job; + +import com.easyagents.flow.core.chain.Parameter; +import org.mockito.MockedStatic; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.enums.EnumJobType; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.job.entity.SysJob; +import tech.easyflow.job.job.JobConstant; +import tech.easyflow.job.service.SysJobService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * {@link SysJobController} 工作流任务参数校验测试。 + */ +public class SysJobControllerTest { + + /** + * 验证缺少工作流必填参数时拒绝保存定时任务。 + */ + @Test + public void shouldRejectWorkflowJobWhenRequiredParameterIsMissing() { + BigInteger workflowId = BigInteger.valueOf(101); + SysJobService jobService = mock(SysJobService.class); + WorkflowService workflowService = mock(WorkflowService.class); + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + WorkflowRunningParameterResolver parameterResolver = + mock(WorkflowRunningParameterResolver.class); + Workflow workflow = new Workflow(); + workflow.setId(workflowId); + workflow.setContent("{}"); + when(workflowService.getById(workflowId)).thenReturn(workflow); + Parameter requiredParameter = mock(Parameter.class); + when(requiredParameter.isRequired()).thenReturn(true); + when(requiredParameter.getName()).thenReturn("user_input"); + when(requiredParameter.getFormLabel()).thenReturn("用户问题"); + when(parameterResolver.resolveStartParameters(workflow.getContent())) + .thenReturn(List.of(requiredParameter)); + SysJobController controller = new SysJobController( + jobService, + workflowService, + resourceAccessService, + parameterResolver + ); + SysJob job = new SysJob(); + job.setJobType(EnumJobType.TINY_FLOW.getCode()); + job.setJobParams(Map.of( + JobConstant.WORKFLOW_KEY, workflowId.toString(), + JobConstant.WORKFLOW_PARAMS_KEY, Map.of() + )); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.ONE); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> controller.onSaveOrUpdateBefore(job, true) + ); + + Assert.assertTrue(exception.getMessage().contains("用户问题")); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java index 7e2b45e5..442bc59d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java @@ -8,7 +8,9 @@ import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.ResourceOfflineImpactService; import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider; import tech.easyflow.ai.vo.OfflineImpactCheckVo; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; import tech.easyflow.approval.service.ApprovalInstanceService; import tech.easyflow.approval.enums.ApprovalResourceType; import tech.easyflow.common.web.exceptions.BusinessException; @@ -18,6 +20,7 @@ import tech.easyflow.system.service.ResourceAccessService; import java.math.BigInteger; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -31,6 +34,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH private final ResourceOfflineImpactService resourceOfflineImpactService; private final WorkflowPluginBindingService workflowPluginBindingService; private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; + private final List workflowScheduleReferenceProviders; public WorkflowApprovalSubjectHandler(WorkflowService workflowService, ResourceAccessService resourceAccessService, @@ -38,13 +42,17 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH ResourceOfflineImpactService resourceOfflineImpactService, WorkflowPluginBindingService workflowPluginBindingService, WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + List workflowScheduleReferenceProviders) { super(approvalInstanceService, objectMapper); this.workflowService = workflowService; this.resourceAccessService = resourceAccessService; this.resourceOfflineImpactService = resourceOfflineImpactService; this.workflowPluginBindingService = workflowPluginBindingService; this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; + this.workflowScheduleReferenceProviders = workflowScheduleReferenceProviders == null + ? List.of() + : List.copyOf(workflowScheduleReferenceProviders); } @Override @@ -186,6 +194,44 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH if (impact.isHasAgentBindings()) { throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除"); } + OfflineImpactBindingVo scheduledJob = findFirstScheduledJobReference(resource.getId()); + if (scheduledJob != null) { + String jobName = scheduledJob.getTitle() == null ? "未命名任务" : scheduledJob.getTitle(); + throw new BusinessException("此工作流仍被定时任务“" + jobName + "”引用,请先删除或重新选择定时任务中的工作流后再删除"); + } + } + + /** + * 审批通过后执行真实删除前,重新校验工作流引用。 + * + * @param resourceId 工作流 ID + * @throws BusinessException 工作流不存在或仍被引用时抛出 + */ + @Override + protected void beforeRemove(BigInteger resourceId) { + Workflow workflow = requireResource(resourceId); + validateDelete(workflow, getCurrentStatus(workflow)); + } + + /** + * 查询第一个引用指定工作流的定时任务。 + * + * @param workflowId 工作流 ID + * @return 定时任务摘要;未被引用时为 null + */ + private OfflineImpactBindingVo findFirstScheduledJobReference(BigInteger workflowId) { + for (WorkflowScheduleReferenceProvider provider : workflowScheduleReferenceProviders) { + List jobs = provider.listScheduledJobsByWorkflowId(workflowId); + if (jobs == null || jobs.isEmpty()) { + continue; + } + for (OfflineImpactBindingVo job : jobs) { + if (job != null) { + return job; + } + } + } + return null; } @Override diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowScheduleReferenceProvider.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowScheduleReferenceProvider.java new file mode 100644 index 00000000..34dd9a8f --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowScheduleReferenceProvider.java @@ -0,0 +1,22 @@ +package tech.easyflow.ai.service; + +import tech.easyflow.ai.vo.OfflineImpactBindingVo; + +import java.math.BigInteger; +import java.util.List; + +/** + * 工作流定时任务引用查询契约。 + * + *

契约定义在 AI 模块中,由定时任务模块实现,避免工作流生命周期反向依赖定时任务实体。

+ */ +public interface WorkflowScheduleReferenceProvider { + + /** + * 查询引用指定工作流的定时任务。 + * + * @param workflowId 工作流 ID + * @return 定时任务摘要列表 + */ + List listScheduledJobsByWorkflowId(BigInteger workflowId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java new file mode 100644 index 00000000..e41c4193 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java @@ -0,0 +1,129 @@ +package tech.easyflow.ai.publish; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; +import tech.easyflow.ai.service.ResourceOfflineImpactService; +import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; +import tech.easyflow.ai.vo.OfflineImpactCheckVo; +import tech.easyflow.approval.enums.ApprovalActionType; +import tech.easyflow.approval.service.ApprovalInstanceService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 工作流删除依赖校验测试。 + */ +public class WorkflowApprovalSubjectHandlerTest { + + /** + * 验证工作流被定时任务引用时不能删除。 + */ + @Test + public void shouldRejectDeleteWhenScheduledJobReferencesWorkflow() { + BigInteger workflowId = BigInteger.valueOf(101); + ResourceOfflineImpactService offlineImpactService = mock(ResourceOfflineImpactService.class); + OfflineImpactCheckVo impact = new OfflineImpactCheckVo(); + impact.setHasAgentBindings(false); + when(offlineImpactService.checkWorkflowImpact(workflowId)).thenReturn(impact); + WorkflowScheduleReferenceProvider scheduleReferenceProvider = ignored -> List.of(binding(201, "每日同步")); + WorkflowApprovalSubjectHandler handler = new WorkflowApprovalSubjectHandler( + mock(WorkflowService.class), + mock(ResourceAccessService.class), + mock(ApprovalInstanceService.class), + offlineImpactService, + mock(WorkflowPluginBindingService.class), + mock(WorkflowPluginSnapshotResolver.class), + new ObjectMapper(), + List.of(scheduleReferenceProvider) + ); + Workflow workflow = new Workflow(); + workflow.setId(workflowId); + + try { + handler.buildDeleteSnapshot(workflow, PublishStatus.OFFLINE); + Assert.fail("工作流被定时任务引用时应阻止删除"); + } catch (BusinessException exception) { + Assert.assertTrue(exception.getMessage().contains("每日同步")); + } + } + + /** + * 验证审批等待期间新增定时任务引用后,真实删除动作会重新校验并阻止删除。 + */ + @Test + public void shouldRecheckScheduledJobReferenceBeforeApprovedDelete() { + BigInteger workflowId = BigInteger.valueOf(102); + WorkflowService workflowService = mock(WorkflowService.class); + ResourceOfflineImpactService offlineImpactService = mock(ResourceOfflineImpactService.class); + OfflineImpactCheckVo impact = new OfflineImpactCheckVo(); + impact.setHasAgentBindings(false); + when(offlineImpactService.checkWorkflowImpact(workflowId)).thenReturn(impact); + AtomicInteger referenceChecks = new AtomicInteger(); + WorkflowScheduleReferenceProvider scheduleReferenceProvider = ignored -> + referenceChecks.incrementAndGet() == 1 + ? List.of() + : List.of(binding(202, "审批期间新增任务")); + WorkflowApprovalSubjectHandler handler = new WorkflowApprovalSubjectHandler( + workflowService, + mock(ResourceAccessService.class), + mock(ApprovalInstanceService.class), + offlineImpactService, + mock(WorkflowPluginBindingService.class), + mock(WorkflowPluginSnapshotResolver.class), + new ObjectMapper(), + List.of(scheduleReferenceProvider) + ); + Workflow workflow = new Workflow(); + workflow.setId(workflowId); + workflow.setPublishStatus(PublishStatus.OFFLINE.getCode()); + when(workflowService.getById(workflowId)).thenReturn(workflow); + + handler.buildDeleteSnapshot(workflow, PublishStatus.OFFLINE); + + try { + handler.applyApprovedAction( + ApprovalActionType.DELETE.getCode(), + workflowId, + Map.of(), + BigInteger.ONE + ); + Assert.fail("审批期间新增定时任务引用后应阻止删除"); + } catch (BusinessException exception) { + Assert.assertTrue(exception.getMessage().contains("审批期间新增任务")); + } + + verify(workflowService, never()).removeById(workflowId); + Assert.assertEquals(2, referenceChecks.get()); + } + + /** + * 创建定时任务引用摘要。 + * + * @param id 定时任务 ID + * @param title 定时任务名称 + * @return 引用摘要 + */ + private OfflineImpactBindingVo binding(long id, String title) { + OfflineImpactBindingVo binding = new OfflineImpactBindingVo(); + binding.setId(BigInteger.valueOf(id)); + binding.setTitle(title); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/SysJobService.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/SysJobService.java index 5be5dbb9..1bfb1d06 100644 --- a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/SysJobService.java +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/SysJobService.java @@ -6,6 +6,7 @@ import tech.easyflow.job.entity.SysJob; import java.io.Serializable; import java.math.BigInteger; import java.util.Collection; +import java.util.List; /** * 系统任务表 服务层。 @@ -26,4 +27,12 @@ public interface SysJobService extends IService { void startJob(BigInteger id); void stopJob(BigInteger id); + + /** + * 查询引用指定工作流的定时任务。 + * + * @param workflowId 工作流 ID + * @return 引用该工作流的定时任务 + */ + List listWorkflowJobsByWorkflowId(BigInteger workflowId); } diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobServiceImpl.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobServiceImpl.java index a0c34aa6..4f007865 100644 --- a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobServiceImpl.java +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobServiceImpl.java @@ -1,10 +1,12 @@ package tech.easyflow.job.service.impl; +import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.quartz.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.constant.enums.EnumMisfirePolicy; import tech.easyflow.common.constant.enums.EnumJobStatus; import tech.easyflow.common.cache.RedisLockExecutor; @@ -14,6 +16,7 @@ import tech.easyflow.job.job.QuartzJob; import tech.easyflow.job.job.QuartzJobNoConcurrent; import tech.easyflow.job.mapper.SysJobMapper; import tech.easyflow.job.service.SysJobService; +import tech.easyflow.job.support.SysJobWorkflowReferenceSupport; import tech.easyflow.job.util.JobUtil; import javax.annotation.Resource; @@ -22,6 +25,7 @@ import java.math.BigInteger; import java.time.Duration; import java.util.Collection; import java.util.Collections; +import java.util.List; /** * 系统任务表 服务层实现。 @@ -153,4 +157,19 @@ public class SysJobServiceImpl extends ServiceImpl implem } }); } + + /** + * {@inheritDoc} + */ + @Override + public List listWorkflowJobsByWorkflowId(BigInteger workflowId) { + if (workflowId == null) { + return List.of(); + } + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(SysJob::getJobType, EnumJobType.TINY_FLOW.getCode()); + return list(queryWrapper).stream() + .filter(job -> workflowId.equals(SysJobWorkflowReferenceSupport.resolveWorkflowId(job))) + .toList(); + } } diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobWorkflowReferenceProvider.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobWorkflowReferenceProvider.java new file mode 100644 index 00000000..3168f0f1 --- /dev/null +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/impl/SysJobWorkflowReferenceProvider.java @@ -0,0 +1,51 @@ +package tech.easyflow.job.service.impl; + +import org.springframework.stereotype.Component; +import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; +import tech.easyflow.job.entity.SysJob; +import tech.easyflow.job.service.SysJobService; + +import java.math.BigInteger; +import java.util.List; + +/** + * 定时任务对工作流的引用查询实现。 + */ +@Component +public class SysJobWorkflowReferenceProvider implements WorkflowScheduleReferenceProvider { + + private final SysJobService sysJobService; + + /** + * 创建定时任务工作流引用查询提供者。 + * + * @param sysJobService 定时任务服务 + */ + public SysJobWorkflowReferenceProvider(SysJobService sysJobService) { + this.sysJobService = sysJobService; + } + + /** + * {@inheritDoc} + */ + @Override + public List listScheduledJobsByWorkflowId(BigInteger workflowId) { + return sysJobService.listWorkflowJobsByWorkflowId(workflowId).stream() + .map(this::toBinding) + .toList(); + } + + /** + * 将定时任务转换为删除影响摘要。 + * + * @param job 定时任务 + * @return 影响摘要 + */ + private OfflineImpactBindingVo toBinding(SysJob job) { + OfflineImpactBindingVo binding = new OfflineImpactBindingVo(); + binding.setId(job.getId()); + binding.setTitle(job.getJobName()); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/support/SysJobWorkflowReferenceSupport.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/support/SysJobWorkflowReferenceSupport.java new file mode 100644 index 00000000..0d23174a --- /dev/null +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/support/SysJobWorkflowReferenceSupport.java @@ -0,0 +1,70 @@ +package tech.easyflow.job.support; + +import tech.easyflow.common.constant.enums.EnumJobType; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.job.entity.SysJob; +import tech.easyflow.job.job.JobConstant; + +import java.math.BigInteger; +import java.util.Map; + +/** + * 工作流类型定时任务的引用解析工具。 + */ +public final class SysJobWorkflowReferenceSupport { + + private SysJobWorkflowReferenceSupport() { + } + + /** + * 判断任务是否为工作流类型。 + * + * @param job 定时任务 + * @return 工作流类型时为 true + */ + public static boolean isWorkflowJob(SysJob job) { + return job != null + && Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(job.getJobType()); + } + + /** + * 解析工作流 ID;参数缺失或格式非法时返回 null。 + * + * @param job 定时任务 + * @return 工作流 ID,无法解析时为 null + */ + public static BigInteger resolveWorkflowId(SysJob job) { + if (!isWorkflowJob(job)) { + return null; + } + Map jobParams = job.getJobParams(); + Object workflowId = jobParams == null ? null : jobParams.get(JobConstant.WORKFLOW_KEY); + if (workflowId == null) { + return null; + } + String value = String.valueOf(workflowId).trim(); + if (value.isEmpty()) { + return null; + } + try { + return new BigInteger(value); + } catch (NumberFormatException ignored) { + return null; + } + } + + /** + * 获取有效的工作流 ID。 + * + * @param job 定时任务 + * @return 工作流 ID + * @throws BusinessException 工作流参数缺失或格式非法时抛出 + */ + public static BigInteger requireWorkflowId(SysJob job) { + BigInteger workflowId = resolveWorkflowId(job); + if (workflowId == null) { + throw new BusinessException("定时任务未配置有效工作流,请重新选择"); + } + return workflowId; + } +} diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java index a0085de2..0d87e0fe 100644 --- a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java @@ -14,10 +14,12 @@ import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.job.entity.SysJob; import tech.easyflow.job.job.JobConstant; +import tech.easyflow.job.support.SysJobWorkflowReferenceSupport; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.service.SysAccountService; import java.lang.reflect.Method; +import java.math.BigInteger; import java.util.Arrays; import java.util.Map; @@ -69,7 +71,7 @@ public class JobUtil { public static Object execWorkFlow(SysJob job) { Map jobParams = job.getJobParams(); JSONObject obj = new JSONObject(jobParams); - String workflowId = obj.getString(JobConstant.WORKFLOW_KEY); + BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(job); JSONObject params = obj.getJSONObject(JobConstant.WORKFLOW_PARAMS_KEY); ChainExecutor executor = SpringContextUtil.getBean(ChainExecutor.class); @@ -79,21 +81,21 @@ public class JobUtil { try { TenantManager.ignoreTenantCondition(); - ChainDefinition chain = executor.getDefinitionRepository().getChainDefinitionById(workflowId); - if (chain != null) { - if (accountId != null) { - // 设置的归属者 - SysAccount account = accountService.getById(accountId.toString()); - if (account != null) { - params.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); - } - } - return executor.execute(workflowId, params); + ChainDefinition chain = executor.getDefinitionRepository().getChainDefinitionById(workflowId.toString()); + if (chain == null) { + throw new IllegalStateException("定时任务关联的工作流不存在或已删除,id=" + workflowId); } + if (accountId != null) { + // 设置的归属者 + SysAccount account = accountService.getById(accountId.toString()); + if (account != null) { + params.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); + } + } + return executor.execute(workflowId.toString(), params); } finally { TenantManager.restoreTenantCondition(); } - return null; } public static Object execute(SysJob job) { diff --git a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.test.ts b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.test.ts new file mode 100644 index 00000000..fcd15d1f --- /dev/null +++ b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.test.ts @@ -0,0 +1,184 @@ +/* eslint-disable vue/one-component-per-file */ + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SysJobModal from './SysJobModal.vue'; + +const apiMocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +vi.mock('#/api/request', () => ({ api: apiMocks })); +vi.mock('#/locales', () => ({ $t: (key: string) => key })); +vi.mock('#/components/cron/CronPicker.vue', () => ({ + default: defineComponent({ name: 'CronPicker', setup: () => () => h('div') }), +})); +vi.mock('#/components/dict/DictSelect.vue', () => ({ + default: defineComponent({ name: 'DictSelect', setup: () => () => h('div') }), +})); +vi.mock('#/views/ai/workflow/components/WorkflowFormItem.vue', () => ({ + default: defineComponent({ + name: 'WorkflowFormItem', + setup: () => () => h('div'), + }), +})); +vi.mock('@easyflow/common-ui', () => ({ + EasyFlowFormModal: defineComponent({ + name: 'EasyFlowFormModal', + props: { + confirmDisabled: Boolean, + confirmLoading: Boolean, + open: Boolean, + }, + emits: ['confirm'], + setup(props, { emit, slots }) { + return () => + props.open + ? h('section', [ + ...(slots.default?.() || []), + h( + 'button', + { + 'data-test': 'confirm', + onClick: () => emit('confirm'), + }, + 'confirm', + ), + ]) + : null; + }, + }), +})); +vi.mock('element-plus', () => ({ + ElAlert: defineComponent({ + name: 'ElAlert', + props: { title: { default: '', type: String } }, + setup(props) { + return () => h('p', props.title); + }, + }), + ElForm: defineComponent({ + name: 'ElForm', + setup(_, { expose, slots }) { + expose({ + resetFields: vi.fn(), + validate: (callback: (valid: boolean) => void) => callback(true), + }); + return () => h('form', slots.default?.()); + }, + }), + ElFormItem: defineComponent({ + name: 'ElFormItem', + setup(_, { slots }) { + return () => h('div', slots.default?.()); + }, + }), + ElInput: defineComponent({ name: 'ElInput', setup: () => () => h('input') }), + ElMessage: { success: vi.fn() }, +})); + +describe('sys job modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMocks.get.mockRejectedValue(new Error('工作流不存在')); + apiMocks.post.mockResolvedValue({ errorCode: 0, message: '保存成功' }); + }); + + it('releases loading and keeps new task creation available after a deleted workflow fails to load', async () => { + const wrapper = mount(SysJobModal, { + global: { + directives: { + loading: { + mounted(element, binding) { + element.dataset.loading = String(binding.value); + }, + updated(element, binding) { + element.dataset.loading = String(binding.value); + }, + }, + }, + }, + }); + + (wrapper.vm as { openDialog: (row: unknown) => void }).openDialog({ + id: 1, + jobParams: { workflowId: '101', workflowParams: {} }, + jobType: 1, + }); + await flushPromises(); + + expect(apiMocks.get).toHaveBeenCalledWith( + '/api/v1/workflow/getRunningParameters?id=101', + ); + expect(wrapper.get('form').attributes('data-loading')).toBe('false'); + expect(wrapper.text()).toContain('所选工作流已不可用,请重新选择'); + expect( + wrapper + .findComponent({ name: 'EasyFlowFormModal' }) + .props('confirmDisabled'), + ).toBe(true); + await wrapper.get('[data-test="confirm"]').trigger('click'); + expect(apiMocks.post).not.toHaveBeenCalled(); + + (wrapper.vm as { openDialog: (row: unknown) => void }).openDialog({}); + await flushPromises(); + + expect(wrapper.get('form').attributes('data-loading')).toBe('false'); + expect( + wrapper + .findComponent({ name: 'EasyFlowFormModal' }) + .props('confirmDisabled'), + ).toBe(false); + }); + + it('blocks save while workflow parameters are loading', async () => { + let resolveParameters: ((value: unknown) => void) | undefined; + apiMocks.get.mockReturnValue( + new Promise((resolve) => { + resolveParameters = resolve; + }), + ); + const wrapper = mount(SysJobModal, { + global: { + directives: { + loading: { + mounted(element, binding) { + element.dataset.loading = String(binding.value); + }, + updated(element, binding) { + element.dataset.loading = String(binding.value); + }, + }, + }, + }, + }); + + (wrapper.vm as { openDialog: (row: unknown) => void }).openDialog({ + id: 1, + jobParams: { workflowId: '101', workflowParams: {} }, + jobType: 1, + }); + await nextTick(); + + const modal = wrapper.findComponent({ name: 'EasyFlowFormModal' }); + expect(modal.props('confirmDisabled')).toBe(true); + expect(modal.props('confirmLoading')).toBe(true); + + await wrapper.get('[data-test="confirm"]').trigger('click'); + expect(apiMocks.post).not.toHaveBeenCalled(); + + resolveParameters?.({ data: { parameters: [] } }); + await flushPromises(); + + expect(modal.props('confirmDisabled')).toBe(false); + expect(modal.props('confirmLoading')).toBe(false); + + await wrapper.get('[data-test="confirm"]').trigger('click'); + await flushPromises(); + expect(apiMocks.post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.vue b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.vue index 56245b34..3cac082a 100644 --- a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.vue +++ b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobModal.vue @@ -1,7 +1,7 @@ @@ -138,7 +172,8 @@ const str = '"param"'; :closable="!btnLoading" :title="isAdd ? $t('button.add') : $t('button.edit')" :before-close="closeDialog" - :confirm-loading="btnLoading" + :confirm-disabled="workflowParamsSubmissionBlocked" + :confirm-loading="btnLoading || paramsLoading" :confirm-text="$t('button.save')" :submitting="btnLoading" @confirm="save" @@ -180,6 +215,13 @@ const str = '"param"'; @change="workflowChange" /> +