feat: 切换定时任务至分布式调度底座
- 以执行账本和有界 Worker 承载重负载任务与故障接管 - 接入统一调度 Starter 并增加 MySQL 迁移、指标和回归测试
This commit is contained in:
@@ -1,26 +1,31 @@
|
||||
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 com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.quartz.CronExpression;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.constant.enums.EnumJobStatus;
|
||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
|
||||
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.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.job.entity.SysJob;
|
||||
import tech.easyflow.job.job.JobConstant;
|
||||
import tech.easyflow.job.service.SysJobService;
|
||||
@@ -32,7 +37,8 @@ import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -61,6 +67,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
/** 工作流运行参数解析器。 */
|
||||
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
|
||||
/** 与调度计算一致的 Cron 预览格式化器。 */
|
||||
private final DateTimeFormatter jobTimeFormatter;
|
||||
|
||||
/**
|
||||
* 创建定时任务控制器。
|
||||
*
|
||||
@@ -69,17 +78,21 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
|
||||
* @param resourceAccessService 资源访问控制服务
|
||||
* @param workflowRunningParameterResolver 工作流运行参数解析器
|
||||
* @param jobTimezone 定时任务业务时区
|
||||
*/
|
||||
public SysJobController(SysJobService service,
|
||||
WorkflowService workflowService,
|
||||
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
WorkflowRunningParameterResolver workflowRunningParameterResolver) {
|
||||
WorkflowRunningParameterResolver workflowRunningParameterResolver,
|
||||
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
|
||||
super(service);
|
||||
this.workflowService = workflowService;
|
||||
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
|
||||
this.jobTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.of(jobTimezone));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,18 +124,43 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/trigger")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
@LogRecord("立即执行定时任务")
|
||||
public Result<String> trigger(BigInteger id) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
SysJob job = requireExistingJob(id);
|
||||
validateWorkflowReference(job, account);
|
||||
return Result.ok(service.triggerNow(id));
|
||||
}
|
||||
|
||||
@GetMapping("/getNextTimes")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
|
||||
CronExpression ex = new CronExpression(cronExpression);
|
||||
List<String> times = new ArrayList<>();
|
||||
Date date = new Date();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
Date next = ex.getNextValidTimeAfter(date);
|
||||
times.add(DateUtil.formatDateTime(next));
|
||||
date = next;
|
||||
public Result<List<String>> getNextTimes(String cronExpression) {
|
||||
return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
|
||||
.map(Date::toInstant)
|
||||
.map(jobTimeFormatter::format)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("remove")
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
|
||||
service.deleteJob(List.of(id));
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("removeBatch")
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public Result<?> removeBatch(
|
||||
@JsonBody(value = "ids", required = true) Collection<Serializable> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Result.fail("id不能为空");
|
||||
}
|
||||
return Result.ok(times);
|
||||
service.deleteJob(ids);
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,15 +174,19 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
|
||||
.eq(Workflow::getTenantId, account.getTenantId())
|
||||
.eq(Workflow::getStatus, EnumDataStatus.AVAILABLE.getCode())
|
||||
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
|
||||
.orderBy(Workflow::getModified, false))
|
||||
.stream()
|
||||
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
|
||||
.filter(workflow -> workflow.getPublishedSnapshotJson() != null
|
||||
&& !workflow.getPublishedSnapshotJson().isEmpty())
|
||||
.filter(workflow -> resourceAccessService.canAccess(
|
||||
account,
|
||||
CategoryResourceType.WORKFLOW,
|
||||
workflow,
|
||||
ResourceAction.USE))
|
||||
.map(workflowService::toPublishedView)
|
||||
.filter(Objects::nonNull)
|
||||
.map(workflow -> new SysJobWorkflowOptionView(
|
||||
workflow.getId(),
|
||||
workflow.getTitle(),
|
||||
@@ -166,7 +208,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
id,
|
||||
SaTokenUtil.getLoginAccount(),
|
||||
"工作流不存在、已禁用或无权运行");
|
||||
"工作流不存在、未发布或无权运行");
|
||||
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (result == null) {
|
||||
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
|
||||
@@ -182,6 +224,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
||||
SysJob effectiveEntity = entity;
|
||||
if (isSave) {
|
||||
// 新任务固定从 STOP 和第 0 代开始,禁止请求绕过启动协议。
|
||||
entity.setStatus(EnumJobStatus.STOP.getCode());
|
||||
entity.setScheduleGeneration(0L);
|
||||
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
||||
} else {
|
||||
SysJob existing = requireExistingJob(entity.getId());
|
||||
@@ -191,9 +236,25 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
entity.setModifiedBy(loginUser.getId());
|
||||
}
|
||||
validateWorkflowReference(effectiveEntity, loginUser);
|
||||
validateCronExpression(effectiveEntity.getCronExpression());
|
||||
validateMisfirePolicy(effectiveEntity.getMisfirePolicy());
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveOrUpdateAfter(SysJob entity, boolean isSave) {
|
||||
service.syncJob(entity.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("update")
|
||||
public Result<?> update(@JsonBody SysJob entity) {
|
||||
Result<?> result = onSaveOrUpdateBefore(entity, false);
|
||||
if (result != null) return result;
|
||||
service.updateJobDefinition(entity);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流类型任务引用的工作流可被当前用户运行。
|
||||
*
|
||||
@@ -210,7 +271,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
workflowId,
|
||||
account,
|
||||
"工作流不存在、已禁用或无权运行");
|
||||
"工作流不存在、未发布或无权运行");
|
||||
validateRequiredWorkflowParams(entity, workflow);
|
||||
}
|
||||
|
||||
@@ -243,6 +304,8 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
entity.setDeptId(existing.getDeptId());
|
||||
entity.setCreated(existing.getCreated());
|
||||
entity.setCreatedBy(existing.getCreatedBy());
|
||||
entity.setStatus(existing.getStatus());
|
||||
entity.setScheduleGeneration(existing.getScheduleGeneration());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,9 +323,30 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
effective.setJobParams(entity.getJobParams() == null
|
||||
? existing.getJobParams()
|
||||
: entity.getJobParams());
|
||||
effective.setCronExpression(entity.getCronExpression() == null
|
||||
? existing.getCronExpression()
|
||||
: entity.getCronExpression());
|
||||
effective.setMisfirePolicy(entity.getMisfirePolicy() == null
|
||||
? existing.getMisfirePolicy()
|
||||
: entity.getMisfirePolicy());
|
||||
return effective;
|
||||
}
|
||||
|
||||
private void validateMisfirePolicy(Integer misfirePolicy) {
|
||||
if (!Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(misfirePolicy)
|
||||
&& !Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(misfirePolicy)) {
|
||||
throw new BusinessException("错过策略只支持恢复后补执行一次或跳过本次");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCronExpression(String cronExpression) {
|
||||
try {
|
||||
service.nextFireTimes(cronExpression, 1);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BusinessException(400, 1, "Cron 表达式无效", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验定时任务已填写工作流的全部必填运行参数。
|
||||
*
|
||||
@@ -322,9 +406,4 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||
service.deleteJob(ids);
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
package tech.easyflow.admin.controller.job;
|
||||
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.job.entity.SysJobLog;
|
||||
import tech.easyflow.job.service.SysJobLogService;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统任务日志 控制层。
|
||||
*
|
||||
@@ -20,16 +34,112 @@ import tech.easyflow.job.service.SysJobLogService;
|
||||
@RequestMapping("/api/v1/sysJobLog")
|
||||
@UsePermission(moduleName = "/api/v1/sysJob")
|
||||
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
|
||||
public SysJobLogController(SysJobLogService service) {
|
||||
private static final long DEFAULT_PAGE_SIZE = 10L;
|
||||
private static final long MAX_PAGE_SIZE = 100L;
|
||||
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ZoneId jobZoneId;
|
||||
|
||||
public SysJobLogController(
|
||||
SysJobLogService service,
|
||||
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
|
||||
super(service);
|
||||
this.jobZoneId = ZoneId.of(jobTimezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造日志筛选条件,并追加计划触发时间和实际触发时间范围。
|
||||
*/
|
||||
@Override
|
||||
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
|
||||
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
|
||||
Date scheduledStart = parseQueryTime(
|
||||
request.getParameter("scheduledStart"), "计划触发开始时间");
|
||||
Date scheduledEnd = parseQueryTime(
|
||||
request.getParameter("scheduledEnd"), "计划触发结束时间");
|
||||
Date actualStart = parseQueryTime(
|
||||
request.getParameter("actualStart"), "实际触发开始时间");
|
||||
Date actualEnd = parseQueryTime(
|
||||
request.getParameter("actualEnd"), "实际触发结束时间");
|
||||
|
||||
validateTimeRange(scheduledStart, scheduledEnd, "计划触发时间");
|
||||
validateTimeRange(actualStart, actualEnd, "实际触发时间");
|
||||
if (scheduledStart != null) {
|
||||
queryWrapper.ge(SysJobLog::getScheduledFireTime, scheduledStart);
|
||||
}
|
||||
if (scheduledEnd != null) {
|
||||
queryWrapper.le(SysJobLog::getScheduledFireTime, scheduledEnd);
|
||||
}
|
||||
if (actualStart != null) {
|
||||
queryWrapper.ge(SysJobLog::getActualFireTime, actualStart);
|
||||
}
|
||||
if (actualEnd != null) {
|
||||
queryWrapper.le(SysJobLog::getActualFireTime, actualEnd);
|
||||
}
|
||||
return queryWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动刷新只读取当前第一页,不执行分页总数统计。
|
||||
*/
|
||||
@GetMapping("refresh")
|
||||
public Result<List<SysJobLog>> refresh(HttpServletRequest request, Long pageSize) {
|
||||
QueryWrapper queryWrapper = buildQueryWrapper(request);
|
||||
queryWrapper.orderBy(buildOrderBy(null, null, getDefaultOrderBy()));
|
||||
queryWrapper.limit(resolvePageSize(pageSize));
|
||||
return Result.ok(service.list(queryWrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新计划触发记录优先,并用主键保证毫秒时间相同时顺序稳定。
|
||||
*/
|
||||
@Override
|
||||
protected String getDefaultOrderBy() {
|
||||
return "scheduled_fire_time desc, id desc";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Page<SysJobLog> queryPage(
|
||||
Page<SysJobLog> page, QueryWrapper queryWrapper) {
|
||||
page.setPageSize(resolvePageSize(page.getPageSize()));
|
||||
return super.queryPage(page, queryWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
|
||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
||||
if (isSave) {
|
||||
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
||||
}
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
throw new IllegalStateException("定时任务执行记录由系统维护,禁止外部写入");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||
service.requireTerminal(ids);
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
|
||||
private long resolvePageSize(Long pageSize) {
|
||||
if (pageSize == null || pageSize < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
private Date parseQueryTime(String value, String fieldName) {
|
||||
if (!StringUtil.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
|
||||
return Date.from(dateTime.atZone(jobZoneId).toInstant());
|
||||
} catch (DateTimeParseException exception) {
|
||||
throw new BusinessException(
|
||||
400, 400, fieldName + "格式不正确", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTimeRange(Date start, Date end, String fieldName) {
|
||||
if (start != null && end != null && start.after(end)) {
|
||||
throw new BusinessException(400, 400, fieldName + "范围不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ public class WorkflowDesignerOptionService {
|
||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
childWorkflowId,
|
||||
account,
|
||||
"子流程不存在、已禁用或无权使用");
|
||||
"子流程不存在、未发布或无权使用");
|
||||
assertContentReferences(workflow.getContent());
|
||||
|
||||
ChainDefinition definition = chainParser.parse(
|
||||
@@ -518,7 +518,7 @@ public class WorkflowDesignerOptionService {
|
||||
workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
workflowId,
|
||||
account,
|
||||
"子流程不存在、已禁用或无权使用");
|
||||
"子流程不存在、未发布或无权使用");
|
||||
}
|
||||
|
||||
private void assertDatasetReference(
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
package tech.easyflow.admin.controller.job;
|
||||
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
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.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||
import tech.easyflow.common.constant.enums.EnumJobStatus;
|
||||
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
|
||||
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.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -31,6 +44,114 @@ import static org.mockito.Mockito.when;
|
||||
*/
|
||||
public class SysJobControllerTest {
|
||||
|
||||
@Test
|
||||
public void shouldQueryPublishedWorkflowOptionsAndReturnPublishedMetadata() {
|
||||
BigInteger workflowId = BigInteger.valueOf(501);
|
||||
LoginAccount account = account();
|
||||
Workflow raw = new Workflow();
|
||||
raw.setId(workflowId);
|
||||
raw.setTenantId(account.getTenantId());
|
||||
raw.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
raw.setPublishedSnapshotJson(Map.of("title", "发布标题"));
|
||||
raw.setTitle("草稿标题");
|
||||
Workflow withoutSnapshot = new Workflow();
|
||||
withoutSnapshot.setId(BigInteger.valueOf(502));
|
||||
withoutSnapshot.setTenantId(account.getTenantId());
|
||||
withoutSnapshot.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
Workflow published = new Workflow();
|
||||
published.setId(workflowId);
|
||||
published.setTitle("发布标题");
|
||||
published.setDescription("发布描述");
|
||||
WorkflowService workflowService = mock(WorkflowService.class);
|
||||
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
|
||||
when(workflowService.list(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of(raw, withoutSnapshot));
|
||||
when(resourceAccessService.canAccess(
|
||||
account,
|
||||
CategoryResourceType.WORKFLOW,
|
||||
raw,
|
||||
ResourceAction.USE)).thenReturn(true);
|
||||
when(workflowService.toPublishedView(raw)).thenReturn(published);
|
||||
SysJobController controller = new SysJobController(
|
||||
mock(SysJobService.class),
|
||||
workflowService,
|
||||
mock(WorkflowUsageAuthorizationService.class),
|
||||
resourceAccessService,
|
||||
mock(WorkflowRunningParameterResolver.class),
|
||||
"Asia/Shanghai");
|
||||
|
||||
List<SysJobWorkflowOptionView> options;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
options = controller.workflowOptions().getData();
|
||||
}
|
||||
|
||||
Assert.assertEquals(options.size(), 1);
|
||||
Assert.assertEquals(options.get(0).title(), "发布标题");
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(workflowService).list(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
|
||||
Assert.assertTrue(sql.contains("publish_status"));
|
||||
Assert.assertFalse(sql.replace("publish_status", "").matches("(?s).*\\bstatus\\b.*"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFormatCronPreviewWithConfiguredTimezone() {
|
||||
SysJobService service = mock(SysJobService.class);
|
||||
when(service.nextFireTimes("0 0 9 * * ?", 5))
|
||||
.thenReturn(List.of(Date.from(Instant.parse("2026-01-01T01:00:00Z"))));
|
||||
TimeZone previous = TimeZone.getDefault();
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
|
||||
Assert.assertEquals(
|
||||
controller(service).getNextTimes("0 0 9 * * ?").getData().get(0),
|
||||
"2026-01-01 09:00:00");
|
||||
} finally {
|
||||
TimeZone.setDefault(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldForceNewJobToStoppedGenerationZero() {
|
||||
SysJobController controller = controller(mock(SysJobService.class));
|
||||
SysJob job = validJavaJob();
|
||||
job.setStatus(EnumJobStatus.RUNNING.getCode());
|
||||
job.setScheduleGeneration(99L);
|
||||
LoginAccount account = account();
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
controller.onSaveOrUpdateBefore(job, true);
|
||||
}
|
||||
|
||||
Assert.assertEquals(job.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
|
||||
Assert.assertEquals(job.getScheduleGeneration(), Long.valueOf(0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectStatusAndGenerationMutationThroughOrdinaryUpdate() {
|
||||
BigInteger id = BigInteger.valueOf(401);
|
||||
SysJobService service = mock(SysJobService.class);
|
||||
SysJob existing = validJavaJob();
|
||||
existing.setId(id);
|
||||
existing.setStatus(EnumJobStatus.STOP.getCode());
|
||||
existing.setScheduleGeneration(8L);
|
||||
when(service.getById(id)).thenReturn(existing);
|
||||
|
||||
SysJob update = validJavaJob();
|
||||
update.setId(id);
|
||||
update.setStatus(EnumJobStatus.RUNNING.getCode());
|
||||
update.setScheduleGeneration(100L);
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account());
|
||||
controller(service).update(update);
|
||||
}
|
||||
|
||||
Assert.assertEquals(update.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
|
||||
Assert.assertEquals(update.getScheduleGeneration(), Long.valueOf(8L));
|
||||
verify(service).updateJobDefinition(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少工作流必填参数时拒绝保存定时任务。
|
||||
*/
|
||||
@@ -63,7 +184,8 @@ public class SysJobControllerTest {
|
||||
workflowService,
|
||||
workflowAuthorizationService,
|
||||
resourceAccessService,
|
||||
parameterResolver
|
||||
parameterResolver,
|
||||
"Asia/Shanghai"
|
||||
);
|
||||
SysJob job = new SysJob();
|
||||
job.setJobType(EnumJobType.TINY_FLOW.getCode());
|
||||
@@ -130,7 +252,8 @@ public class SysJobControllerTest {
|
||||
workflowService,
|
||||
workflowAuthorizationService,
|
||||
resourceAccessService,
|
||||
parameterResolver
|
||||
parameterResolver,
|
||||
"Asia/Shanghai"
|
||||
);
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
@@ -147,4 +270,34 @@ public class SysJobControllerTest {
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
}
|
||||
|
||||
private static SysJobController controller(SysJobService service) {
|
||||
return new SysJobController(
|
||||
service,
|
||||
mock(WorkflowService.class),
|
||||
mock(WorkflowUsageAuthorizationService.class),
|
||||
mock(ResourceAccessService.class),
|
||||
mock(WorkflowRunningParameterResolver.class),
|
||||
"Asia/Shanghai");
|
||||
}
|
||||
|
||||
private static SysJob validJavaJob() {
|
||||
SysJob job = new SysJob();
|
||||
job.setJobName("generation-test");
|
||||
job.setJobType(EnumJobType.JAVA_CLASS.getCode());
|
||||
job.setCronExpression("0 0 0 1 1 ? 2099");
|
||||
job.setMisfirePolicy(EnumMisfirePolicy.SKIP.getCode());
|
||||
job.setAllowConcurrent(0);
|
||||
job.setJobParams(Map.of(JobConstant.JAVA_METHOD_KEY,
|
||||
"tech.easyflow.job.util.JobUtil.test()"));
|
||||
return job;
|
||||
}
|
||||
|
||||
private static LoginAccount account() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.ONE);
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
account.setDeptId(BigInteger.ONE);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package tech.easyflow.admin.controller.job;
|
||||
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.job.entity.SysJobLog;
|
||||
import tech.easyflow.job.service.SysJobLogService;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SysJobLogController} 查询与轻量刷新边界测试。
|
||||
*/
|
||||
public class SysJobLogControllerTest {
|
||||
|
||||
@Test
|
||||
public void shouldBuildBothFireTimeRanges() {
|
||||
SysJobLogController controller = controller(mock(SysJobLogService.class));
|
||||
HttpServletRequest request = emptyRequest();
|
||||
when(request.getParameter("scheduledStart")).thenReturn("2026-08-31 10:00:00");
|
||||
when(request.getParameter("scheduledEnd")).thenReturn("2026-08-31 11:00:00");
|
||||
when(request.getParameter("actualStart")).thenReturn("2026-08-31 10:00:01");
|
||||
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 11:00:01");
|
||||
|
||||
String sql = controller.buildQueryWrapper(request).toSQL().toLowerCase(Locale.ROOT);
|
||||
|
||||
Assert.assertEquals(countOccurrences(sql, "scheduled_fire_time"), 2);
|
||||
Assert.assertEquals(countOccurrences(sql, "actual_fire_time"), 2);
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = BusinessException.class)
|
||||
public void shouldRejectInvalidFireTime() {
|
||||
SysJobLogController controller = controller(mock(SysJobLogService.class));
|
||||
HttpServletRequest request = emptyRequest();
|
||||
when(request.getParameter("scheduledStart")).thenReturn("2026/08/31 10:00:00");
|
||||
|
||||
controller.buildQueryWrapper(request);
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = BusinessException.class)
|
||||
public void shouldRejectReversedActualFireTimeRange() {
|
||||
SysJobLogController controller = controller(mock(SysJobLogService.class));
|
||||
HttpServletRequest request = emptyRequest();
|
||||
when(request.getParameter("actualStart")).thenReturn("2026-08-31 11:00:00");
|
||||
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 10:00:00");
|
||||
|
||||
controller.buildQueryWrapper(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldClampRefreshAndPageSize() {
|
||||
SysJobLogService service = mock(SysJobLogService.class);
|
||||
when(service.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
when(service.page(any(Page.class), any(QueryWrapper.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
SysJobLogController controller = controller(service);
|
||||
HttpServletRequest request = emptyRequest();
|
||||
|
||||
controller.refresh(request, 500L);
|
||||
Page<SysJobLog> page = controller.queryPage(
|
||||
new Page<>(1, 500), QueryWrapper.create());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor =
|
||||
ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(service).list(queryCaptor.capture());
|
||||
String refreshSql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
|
||||
Assert.assertTrue(refreshSql.contains("limit 100"));
|
||||
Assert.assertEquals(page.getPageSize(), 100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUseStableScheduledFireTimeOrder() {
|
||||
Assert.assertEquals(
|
||||
controller(mock(SysJobLogService.class)).getDefaultOrderBy(),
|
||||
"scheduled_fire_time desc, id desc");
|
||||
}
|
||||
|
||||
private static SysJobLogController controller(SysJobLogService service) {
|
||||
return new SysJobLogController(service, "Asia/Shanghai");
|
||||
}
|
||||
|
||||
private static HttpServletRequest emptyRequest() {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getParameterMap()).thenReturn(Collections.emptyMap());
|
||||
return request;
|
||||
}
|
||||
|
||||
private static int countOccurrences(String source, String expected) {
|
||||
return (source.length() - source.replace(expected, "").length())
|
||||
/ expected.length();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user