Compare commits

...

8 Commits

Author SHA1 Message Date
263f5f4b8b feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
2026-08-31 16:45:21 +08:00
6daf805cd0 feat: M28 增加工作流汇聚可视化配置 2026-08-31 15:55:05 +08:00
4386b2a1a6 feat: M28 增加工作流汇聚安全校验 2026-08-31 15:54:50 +08:00
f28e3919ac fix: 修复智能体聊天图片能力判断 2026-08-31 15:10:40 +08:00
0cedf85729 fix: 统一工作流空媒体参数处理 2026-08-31 15:00:49 +08:00
155af9989c feat: 优化定时任务管理与日志体验
- 增加工作流选项、时间范围筛选和日志详情展示

- 支持可配置自动刷新、轻量局部更新与响应式布局
2026-08-31 14:57:20 +08:00
8c174e5c02 feat: 切换定时任务至分布式调度底座
- 以执行账本和有界 Worker 承载重负载任务与故障接管

- 接入统一调度 Starter 并增加 MySQL 迁移、指标和回归测试
2026-08-31 14:57:08 +08:00
17ef189862 fix: 补全前端容器公共 API 代理
- 将 /flow/public-api/ 请求转发到后端 /public-api/

- 保持工作流等公共接口的同源访问路径
2026-08-31 13:46:54 +08:00
139 changed files with 11977 additions and 910 deletions

View File

@@ -0,0 +1,117 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.util.Map;
/**
* 工作流对话匿名分享接口。
*/
@SaIgnore
@RestController
@RequestMapping("/api/v1/workflowChat/public")
public class WorkflowPublicChatController {
private final WorkflowPublicChatService publicChatService;
public WorkflowPublicChatController(
WorkflowPublicChatService publicChatService
) {
this.publicChatService = publicChatService;
}
@GetMapping("/descriptor")
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
return Result.ok(publicChatService.descriptor(
shareKey(request),
visitorId(request)
));
}
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter run(
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request
) {
return publicChatService.run(
shareKey(request),
visitorId(request),
variables
);
}
@GetMapping("/execution")
public Result<Map<String, Object>> execution(
@RequestParam String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.detail(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/cancel")
public Result<Boolean> cancel(
@JsonBody(value = "executeId", required = true) String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.cancel(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/resume")
public Result<Void> resume(
@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams,
HttpServletRequest request
) {
publicChatService.resume(
shareKey(request),
visitorId(request),
executeId,
confirmParams
);
return Result.ok();
}
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
public Result<UploadResVo> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("parameterName") String parameterName,
HttpServletRequest request
) {
return Result.ok(publicChatService.upload(
shareKey(request),
visitorId(request),
parameterName,
file
));
}
private String shareKey(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
}
private String visitorId(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -96,11 +97,10 @@ public class WorkflowShareController {
* @return 工作流标识
*/
@GetMapping("/resolve")
@SaIgnore
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
WorkflowShare share = workflowShareService.resolveChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
loginAccount.getTenantId()
WorkflowShare share = workflowShareService.resolvePublicChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
);
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
}

View File

@@ -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);
}
}

View File

@@ -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 + "范围不正确");
}
}
}

View File

@@ -19,11 +19,17 @@ import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -40,6 +46,15 @@ public class WorkflowChatEventStream {
private final ChainExecutor chainExecutor;
private final Map<String, StreamSession> sessions =
new ConcurrentHashMap<>();
private final ScheduledExecutorService detachedSessionCleaner =
Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(
task,
"workflow-chat-detached-session-cleaner"
);
thread.setDaemon(true);
return thread;
});
/**
* 创建工作流对话事件流服务。
@@ -59,6 +74,15 @@ public class WorkflowChatEventStream {
chainExecutor.addErrorListener(this::onChainError);
}
/**
* 关闭断开会话清理线程并释放残留外部资源。
*/
@PreDestroy
public void shutdown() {
sessions.values().forEach(this::removeSession);
detachedSessionCleaner.shutdownNow();
}
/**
* 启动工作流并返回其 SSE 连接。
*
@@ -67,11 +91,53 @@ public class WorkflowChatEventStream {
* @return SSE 连接
*/
public SseEmitter start(String definitionId, Map<String, Object> variables) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
StreamSession session = new StreamSession(emitter);
emitter.onTimeout(() -> disconnect(session, "运行连接超时"));
emitter.onError(error -> disconnect(session, "运行连接已断开"));
emitter.onCompletion(() -> removeSession(session));
return start(definitionId, variables, () -> {
});
}
/**
* 启动工作流并在流会话结束时执行清理回调。
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup
) {
return start(definitionId, variables, cleanup, Duration.ZERO);
}
/**
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
*
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
* 超过保留期时由租约兜底释放资源。</p>
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
* @param detachedRetention 浏览器断开后的监听保留时长
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup,
Duration detachedRetention
) {
SseEmitter emitter = createEmitter();
StreamSession session = new StreamSession(
emitter,
cleanup,
detachedRetention
);
emitter.onTimeout(() -> detach(session));
emitter.onError(error -> detach(session));
emitter.onCompletion(() -> detach(session));
try {
chainExecutor.executeAsync(
@@ -79,6 +145,9 @@ public class WorkflowChatEventStream {
variables,
executeId -> {
session.attach(executeId);
if (session.cleaned.get()) {
return;
}
sessions.put(executeId, session);
session.send("execution_started", Map.of(
"executeId", executeId
@@ -92,6 +161,13 @@ public class WorkflowChatEventStream {
return emitter;
}
/**
* 创建 SSE 发送器,便于验证连接生命周期。
*/
SseEmitter createEmitter() {
return new SseEmitter(SSE_TIMEOUT_MILLIS);
}
/**
* 将工作流事件转发到对应执行流。
*
@@ -162,20 +238,21 @@ public class WorkflowChatEventStream {
}
/**
* 处理 SSE 连接异常,并取消尚未结束的工作流
* 分离已经断开的浏览器传输,不影响工作流 Runtime
*
* @param session 流会话
* @param message 取消原因
*/
private void disconnect(StreamSession session, String message) {
private void detach(StreamSession session) {
if (session == null || session.terminal.get()) {
return;
}
String executeId = session.executeId;
removeSession(session);
if (executeId != null) {
chainExecutor.cancel(executeId, message);
session.detachTransport();
if (session.detachedRetention.isZero()
|| session.detachedRetention.isNegative()) {
removeSession(session);
return;
}
session.scheduleDetachedCleanup();
}
/**
@@ -187,6 +264,9 @@ public class WorkflowChatEventStream {
if (session != null && session.executeId != null) {
sessions.remove(session.executeId, session);
}
if (session != null) {
session.cleanup();
}
}
/**
@@ -231,6 +311,11 @@ public class WorkflowChatEventStream {
private final SseEmitter emitter;
private final AtomicLong sequence = new AtomicLong();
private final AtomicBoolean terminal = new AtomicBoolean(false);
private final AtomicBoolean cleaned = new AtomicBoolean(false);
private final AtomicBoolean connected = new AtomicBoolean(true);
private final Runnable cleanup;
private final Duration detachedRetention;
private volatile ScheduledFuture<?> detachedCleanup;
private volatile String executeId;
/**
@@ -238,8 +323,36 @@ public class WorkflowChatEventStream {
*
* @param emitter SSE 发送器
*/
private StreamSession(SseEmitter emitter) {
private StreamSession(
SseEmitter emitter,
Runnable cleanup,
Duration detachedRetention
) {
this.emitter = emitter;
this.cleanup = cleanup == null ? () -> {
} : cleanup;
this.detachedRetention = detachedRetention == null
? Duration.ZERO
: detachedRetention;
}
/**
* 幂等释放当前流持有的外部资源。
*/
private void cleanup() {
if (!cleaned.compareAndSet(false, true)) {
return;
}
cancelDetachedCleanup();
try {
cleanup.run();
} catch (RuntimeException error) {
log.warn(
"workflow chat stream cleanup failed, executeId={}",
executeId,
error
);
}
}
/**
@@ -251,6 +364,38 @@ public class WorkflowChatEventStream {
this.executeId = executeId;
}
/**
* 标记浏览器传输已经断开,后续事件只推进 Runtime 清理。
*/
private void detachTransport() {
connected.set(false);
}
/**
* 浏览器断开后按活动租约安排会话兜底清理。
*/
private synchronized void scheduleDetachedCleanup() {
if (detachedCleanup != null || cleaned.get()) {
return;
}
detachedCleanup = detachedSessionCleaner.schedule(
() -> removeSession(this),
Math.max(1L, detachedRetention.toMillis()),
TimeUnit.MILLISECONDS
);
}
/**
* 取消尚未触发的断开会话兜底任务。
*/
private synchronized void cancelDetachedCleanup() {
if (detachedCleanup == null) {
return;
}
detachedCleanup.cancel(false);
detachedCleanup = null;
}
/**
* 处理节点开始事件。
*
@@ -424,7 +569,9 @@ public class WorkflowChatEventStream {
}
send(eventType, data);
removeSession(this);
emitter.complete();
if (connected.compareAndSet(true, false)) {
emitter.complete();
}
}
/**
@@ -434,6 +581,9 @@ public class WorkflowChatEventStream {
* @param data 事件数据
*/
private void send(String type, Map<String, ?> data) {
if (!connected.get()) {
return;
}
long nextSequence = sequence.incrementAndGet();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventId", executeId + ":" + nextSequence);
@@ -453,7 +603,7 @@ public class WorkflowChatEventStream {
executeId,
error
);
disconnect(this, "运行连接已断开");
detach(this);
}
}
@@ -468,7 +618,9 @@ public class WorkflowChatEventStream {
"message", safeErrorMessage(error)
));
removeSession(this);
emitter.completeWithError(error);
if (connected.compareAndSet(true, false)) {
emitter.completeWithError(error);
}
}
}

View File

@@ -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(

View File

@@ -0,0 +1,165 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
/**
* 工作流匿名分享的限流与活动执行互斥保护。
*/
@Component
public class WorkflowPublicChatAccessGuard {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatAccessGuard.class);
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
static {
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
RATE_LIMIT_SCRIPT.setScriptText(
"local visitor = redis.call('incr', KEYS[1]); "
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
+ "local share = redis.call('incr', KEYS[2]); "
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
+ "then return 0 else return 1 end"
);
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
private final StringRedisTemplate redisTemplate;
private final RedisLockExecutor redisLockExecutor;
private final WorkflowPublicShareProperties properties;
public WorkflowPublicChatAccessGuard(
StringRedisTemplate redisTemplate,
RedisLockExecutor redisLockExecutor,
WorkflowPublicShareProperties properties
) {
this.redisTemplate = redisTemplate;
this.redisLockExecutor = redisLockExecutor;
this.properties = properties;
}
/**
* 检查匿名运行固定窗口限流。
*/
public void checkRun(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"run",
properties.getRunVisitorLimit(),
properties.getRunShareLimit()
);
}
/**
* 检查匿名上传固定窗口限流。
*/
public void checkUpload(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"upload",
properties.getUploadVisitorLimit(),
properties.getUploadShareLimit()
);
}
/**
* 获取同一分享访客的活动执行锁。
*
* @return 由 SSE 生命周期显式释放的锁句柄
*/
public RedisLockExecutor.LockHandle acquireActivity(
BigInteger shareId,
String visitorDigest
) {
try {
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
Duration.ZERO,
properties.getActiveLease()
);
if (handle == null) {
throw new BusinessException(
409,
40931,
"当前分享访客已有工作流正在运行"
);
}
return handle;
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流活动锁暂不可用shareId={}", shareId, exception);
throw unavailable(exception);
}
}
/**
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
*/
public Duration activityLease() {
return properties.getActiveLease();
}
private void checkRate(
BigInteger shareId,
String visitorDigest,
String action,
int visitorLimit,
int shareLimit
) {
String slot = "{" + shareId + "}";
List<String> keys = List.of(
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
KEY_PREFIX + slot + ":rate:" + action + ":share"
);
try {
Long allowed = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
keys,
String.valueOf(visitorLimit),
String.valueOf(shareLimit),
String.valueOf(properties.getRateWindow().toMillis())
);
if (allowed == null) {
throw unavailable(new IllegalStateException(
"Redis 未返回匿名工作流限流结果"));
}
if (!Long.valueOf(1L).equals(allowed)) {
throw new BusinessException(
429,
42931,
"匿名工作流请求过于频繁,请稍后重试"
);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流限流暂不可用shareId={}, action={}",
shareId, action, exception);
throw unavailable(exception);
}
}
private BusinessException unavailable(RuntimeException cause) {
return new BusinessException(
503,
50331,
"匿名工作流保护服务暂不可用,请稍后重试",
cause
);
}
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.admin.service.ai;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.common.entity.LoginAccount;
/**
* 完成匿名分享边界校验后的运行上下文。
*/
public record WorkflowPublicChatContext(
WorkflowShare share,
Workflow workflow,
LoginAccount creator,
String shareKey,
String visitorDigest
) {
}

View File

@@ -0,0 +1,121 @@
package tech.easyflow.admin.service.ai;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* 解析并校验工作流匿名分享上下文。
*/
@Service
public class WorkflowPublicChatContextResolver {
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
private final WorkflowShareService shareService;
private final WorkflowService workflowService;
private final SysAccountService accountService;
public WorkflowPublicChatContextResolver(
WorkflowShareService shareService,
WorkflowService workflowService,
SysAccountService accountService
) {
this.shareService = shareService;
this.workflowService = workflowService;
this.accountService = accountService;
}
/**
* 解析新运行、恢复与上传所需的当前有效上下文。
*/
public WorkflowPublicChatContext resolveActive(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
Workflow workflow = TenantManager.withoutTenantCondition(
() -> workflowService.getPublishedById(share.getWorkflowId()));
if (!isStrictlyPublished(workflow)
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
SysAccount account = TenantManager.withoutTenantCondition(
() -> accountService.getById(share.getCreatedBy()));
if (account == null
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
throw new BusinessException(
403,
40331,
"工作流分享创建者账号当前不可用"
);
}
LoginAccount creator = account.toLoginAccount();
return new WorkflowPublicChatContext(
share,
workflow,
creator,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
/**
* 解析已发起执行的详情与取消所需历史上下文。
*/
public WorkflowPublicChatContext resolveHistorical(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
return new WorkflowPublicChatContext(
share,
null,
null,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
private String requireVisitor(String visitorId) {
String normalized = visitorId == null ? "" : visitorId.trim();
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
throw new BusinessException(
400,
40031,
"工作流分享访客标识无效"
);
}
return normalized;
}
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(
workflow.getPublishStatus())
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -0,0 +1,338 @@
package tech.easyflow.admin.service.ai;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 工作流匿名分享对话应用服务。
*/
@Service
public class WorkflowPublicChatService {
private static final Logger log =
LoggerFactory.getLogger(WorkflowPublicChatService.class);
private final WorkflowPublicChatContextResolver contextResolver;
private final WorkflowCheckService workflowCheckService;
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatUploadService uploadService;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowChatEventStream eventStream;
private final ChainExecutor chainExecutor;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
public WorkflowPublicChatService(
WorkflowPublicChatContextResolver contextResolver,
WorkflowCheckService workflowCheckService,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatUploadService uploadService,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
this.contextResolver = contextResolver;
this.workflowCheckService = workflowCheckService;
this.parameterResolver = parameterResolver;
this.uploadService = uploadService;
this.accessGuard = accessGuard;
this.eventStream = eventStream;
this.chainExecutor = chainExecutor;
this.execResultService = execResultService;
this.execStepService = execStepService;
}
/**
* 获取匿名分享的发布工作流描述。
*/
public Map<String, Object> descriptor(String shareKey, String visitorId) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
checkWorkflow(context);
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
descriptor.put("workflowId", context.workflow().getId());
descriptor.put("publishStatus", context.workflow().getPublishStatus());
descriptor.put("shareable", false);
return descriptor;
}
/**
* 启动匿名分享工作流并返回 SSE。
*/
public SseEmitter run(
String shareKey,
String visitorId,
Map<String, Object> variables
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
accessGuard.checkRun(
context.share().getId(),
context.visitorDigest()
);
checkWorkflow(context);
Map<String, Object> normalized = parameterResolver
.normalizeRuntimeVariables(
context.workflow().getContent(),
variables
);
uploadService.assertOwnedUploads(context, normalized);
normalized.put(Constants.LOGIN_USER_KEY, context.creator());
normalized.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId())
);
normalized.put(
WorkFlowUtil.CREATED_BY_MEMORY_KEY,
context.visitorDigest()
);
RedisLockExecutor.LockHandle activity = accessGuard.acquireActivity(
context.share().getId(),
context.visitorDigest()
);
try {
return eventStream.start(
PublishedWorkflowDefinitionIds.published(
context.workflow().getId().toString()),
normalized,
activity::release,
accessGuard.activityLease()
);
} catch (RuntimeException | Error error) {
activity.release();
throw error;
}
}
/**
* 获取当前匿名访客发起的执行详情。
*/
public Map<String, Object> detail(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
List<WorkflowExecStep> steps = TenantManager.withoutTenantCondition(
() -> execStepService.list(
QueryWrapper.create()
.eq(WorkflowExecStep::getRecordId, record.getId())
.orderBy(WorkflowExecStep::getStartTime, true)
));
return buildExecutionDetail(record, steps, runtimeView(executeId));
}
/**
* 取消当前匿名访客发起的执行。
*/
public boolean cancel(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
assertExecutionOwnership(context, executeId);
return chainExecutor.cancel(executeId, "匿名访客已中止运行");
}
/**
* 恢复当前有效分享访客等待确认的执行。
*/
public void resume(
String shareKey,
String visitorId,
String executeId,
Map<String, Object> confirmParams
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
if (isTerminal(record.getStatus())) {
throw new BusinessException("当前工作流执行已结束");
}
chainExecutor.resumeAsync(
executeId,
confirmParams == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(confirmParams)
);
}
/**
* 上传当前发布快照声明的匿名输入文件。
*/
public UploadResVo upload(
String shareKey,
String visitorId,
String parameterName,
MultipartFile file
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
return uploadService.upload(context, parameterName, file);
}
private void checkWorkflow(WorkflowPublicChatContext context) {
TenantManager.withoutTenantCondition(() -> {
workflowCheckService.checkOrThrow(
context.workflow().getContent(),
WorkflowCheckStage.PRE_EXECUTE,
context.workflow().getId()
);
return null;
});
}
private WorkflowExecResult assertExecutionOwnership(
WorkflowPublicChatContext context,
String executeId
) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = TenantManager.withoutTenantCondition(
() -> execResultService.getByExecKey(executeId));
if (record == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
String expectedSource = WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId());
if (!Objects.equals(expectedSource, record.getCreatedKey())
|| !Objects.equals(
context.visitorDigest(),
record.getCreatedBy())
|| !Objects.equals(
context.share().getWorkflowId(),
record.getWorkflowId())) {
throw new BusinessException(
403,
40333,
"无权限访问当前工作流执行记录"
);
}
return record;
}
private boolean isTerminal(Integer status) {
return status != null
&& (status == ChainStatus.SUCCEEDED.getValue()
|| status == ChainStatus.FAILED.getValue()
|| status == ChainStatus.CANCELLED.getValue());
}
private Map<String, Object> buildExecutionDetail(
WorkflowExecResult record,
List<WorkflowExecStep> steps,
Map<String, Object> runtime
) {
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
for (WorkflowExecStep step : steps) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("id", step.getId());
view.put("attemptKey", step.getExecKey());
view.put("nodeId", step.getNodeId());
view.put("nodeName", step.getNodeName());
view.put("input", step.getInput());
view.put("output", step.getOutput());
view.put("status", step.getStatus());
view.put("errorInfo", step.getErrorInfo());
view.put("startTime", step.getStartTime());
view.put("endTime", step.getEndTime());
view.put("execTime", step.getExecTime());
stepViews.add(view);
}
Map<String, Object> recordView = new LinkedHashMap<>();
recordView.put("executeId", record.getExecKey());
recordView.put("workflowId", record.getWorkflowId());
recordView.put("title", record.getTitle());
recordView.put("status", record.getStatus());
recordView.put("input", record.getInput());
recordView.put("output", record.getOutput());
recordView.put("errorInfo", record.getErrorInfo());
recordView.put("startTime", record.getStartTime());
recordView.put("endTime", record.getEndTime());
recordView.put("execTime", record.getExecTime());
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("record", recordView);
detail.put("steps", stepViews);
detail.put("runtime", runtime);
return detail;
}
/**
* 构建刷新恢复所需的最小 Runtime 视图。
*/
private Map<String, Object> runtimeView(String executeId) {
try {
ChainState state = chainExecutor.getChainStateRepository()
.load(executeId);
if (state == null || state.getStatus() == null) {
return Map.of();
}
Map<String, Object> view = new LinkedHashMap<>();
view.put("status", state.getStatus().name());
view.put("statusValue", state.getStatus().getValue());
view.put("message", state.getMessage());
if (state.getStatus() == ChainStatus.SUSPEND) {
view.put("parameters", state.getSuspendForParameters());
}
if (state.getStatus() == ChainStatus.SUCCEEDED) {
view.put(
"output",
WorkflowChatEventStream.visibleFinalOutput(
state.getExecuteResult())
);
}
return view;
} catch (RuntimeException error) {
log.warn(
"failed to load public workflow runtime state, executeId={}",
executeId,
error
);
return Map.of();
}
}
}

View File

@@ -0,0 +1,325 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 工作流匿名分享的隔离上传与运行引用校验。
*/
@Service
public class WorkflowPublicChatUploadService {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatUploadService.class);
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
"bmp", "gif", "jpeg", "jpg", "png", "webp");
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowPublicShareProperties properties;
private final StringRedisTemplate redisTemplate;
private final FileStorageService storageService;
public WorkflowPublicChatUploadService(
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowPublicShareProperties properties,
StringRedisTemplate redisTemplate,
@Qualifier("default") FileStorageService storageService
) {
this.parameterResolver = parameterResolver;
this.accessGuard = accessGuard;
this.properties = properties;
this.redisTemplate = redisTemplate;
this.storageService = storageService;
}
/**
* 上传发布快照声明的文件或图片参数。
*/
public UploadResVo upload(
WorkflowPublicChatContext context,
String parameterName,
MultipartFile file
) {
String normalizedName = requireParameterName(parameterName);
String contentType = resolveUploadContentType(context, normalizedName);
validateFile(file, contentType);
accessGuard.checkUpload(
context.share().getId(),
context.visitorDigest()
);
String path = storageService.save(
file,
"workflow-chat-share/" + context.share().getId()
+ "/" + context.visitorDigest()
);
if (!StringUtils.hasText(path)) {
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
}
try {
redisTemplate.opsForValue().set(
grantKey(context, normalizedName, path),
contentType,
grantTtl(context).toMillis(),
TimeUnit.MILLISECONDS
);
} catch (RuntimeException exception) {
try {
storageService.delete(path);
} catch (RuntimeException cleanupError) {
log.warn("匿名上传授权写入失败后清理文件失败path={}",
path, cleanupError);
}
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
UploadResVo response = new UploadResVo();
response.setPath(path);
return response;
}
/**
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
*/
public void assertOwnedUploads(
WorkflowPublicChatContext context,
Map<String, Object> variables
) {
Map<String, String> uploadFields = resolveUploadFields(context);
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
Object value = variables.get(entry.getKey());
if (value == null) {
continue;
}
if ("image".equals(entry.getValue())) {
assertOwnedImage(context, entry.getKey(), value);
} else {
assertOwnedFiles(context, entry.getKey(), value);
}
}
}
private void assertOwnedImage(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Map<?, ?> image)) {
throw invalidUploadReference(parameterName);
}
String sourceType = trim(image.get("sourceType"));
if ("url".equals(sourceType)) {
String url = trim(image.get("url"));
if (isHttpUrl(url)) {
return;
}
throw invalidUploadReference(parameterName);
}
if (!"upload".equals(sourceType)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(image.get("filePath")),
"image"
);
}
private void assertOwnedFiles(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Collection<?> files)) {
throw invalidUploadReference(parameterName);
}
for (Object item : files) {
if (!(item instanceof Map<?, ?> file)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(file.get("filePath")),
"file"
);
}
}
private void assertGrant(
WorkflowPublicChatContext context,
String parameterName,
String path,
String expectedContentType
) {
if (!StringUtils.hasText(path)) {
throw invalidUploadReference(parameterName);
}
try {
String grantedContentType = redisTemplate.opsForValue().get(
grantKey(context, parameterName, path));
if (!expectedContentType.equals(grantedContentType)) {
throw invalidUploadReference(parameterName);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
}
private String resolveUploadContentType(
WorkflowPublicChatContext context,
String parameterName
) {
String contentType = resolveUploadFields(context).get(parameterName);
if (contentType == null) {
throw new BusinessException(
400,
40032,
"当前发布工作流未声明该上传参数"
);
}
return contentType;
}
@SuppressWarnings("unchecked")
private Map<String, String> resolveUploadFields(
WorkflowPublicChatContext context
) {
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
Map<String, String> fields = new java.util.LinkedHashMap<>();
Object rawSchema = descriptor.get("startFormSchema");
if (!(rawSchema instanceof Collection<?> schema)) {
return fields;
}
for (Object item : schema) {
if (!(item instanceof Map<?, ?> field)) {
continue;
}
String name = trim(field.get("key"));
String contentType = trim(field.get("contentType"));
if (StringUtils.hasText(name)
&& ("file".equals(contentType)
|| "image".equals(contentType))) {
fields.put(name, contentType);
}
}
return fields;
}
private void validateFile(MultipartFile file, String contentType) {
if (file == null || file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
long maxSize = "image".equals(contentType)
? IMAGE_MAX_SIZE
: FILE_MAX_SIZE;
if (file.getSize() > maxSize) {
throw new BusinessException(
"image".equals(contentType)
? "单张图片不能超过 10 MiB"
: "单个文件不能超过 100 MiB"
);
}
if (!"image".equals(contentType)) {
return;
}
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
String filename = trim(file.getOriginalFilename());
int dot = filename.lastIndexOf('.');
String extension = dot < 0
? ""
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!IMAGE_MIME_TYPES.contains(mimeType)
&& !IMAGE_EXTENSIONS.contains(extension)) {
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
}
}
private Duration grantTtl(WorkflowPublicChatContext context) {
long expiresIn = context.share().getExpiresAt().getTime()
- System.currentTimeMillis();
long ttl = Math.min(
properties.getUploadGrantTtl().toMillis(),
expiresIn
);
return Duration.ofMillis(Math.max(1L, ttl));
}
private String grantKey(
WorkflowPublicChatContext context,
String parameterName,
String path
) {
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
+ context.visitorDigest() + ":"
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
+ WorkflowSharePolicy.hashShareKey(path);
}
private String requireParameterName(String value) {
String normalized = value == null ? "" : value.trim();
if (!StringUtils.hasText(normalized)) {
throw new BusinessException("上传参数名不能为空");
}
return normalized;
}
private String trim(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private boolean isHttpUrl(String value) {
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
return normalized.startsWith("http://")
|| normalized.startsWith("https://");
}
private BusinessException invalidUploadReference(String parameterName) {
return new BusinessException(
403,
40332,
"上传参数 " + parameterName + " 不属于当前分享访客"
);
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.admin.service.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 工作流匿名分享运行保护参数。
*/
@Component
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
public class WorkflowPublicShareProperties {
private Duration rateWindow = Duration.ofMinutes(1);
private int runVisitorLimit = 5;
private int runShareLimit = 60;
private int uploadVisitorLimit = 10;
private int uploadShareLimit = 60;
private Duration activeLease = Duration.ofMinutes(35);
private Duration uploadGrantTtl = Duration.ofDays(7);
public Duration getRateWindow() {
return rateWindow;
}
public void setRateWindow(Duration rateWindow) {
this.rateWindow = requirePositive(rateWindow, "rateWindow");
}
public int getRunVisitorLimit() {
return runVisitorLimit;
}
public void setRunVisitorLimit(int runVisitorLimit) {
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
}
public int getRunShareLimit() {
return runShareLimit;
}
public void setRunShareLimit(int runShareLimit) {
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
}
public int getUploadVisitorLimit() {
return uploadVisitorLimit;
}
public void setUploadVisitorLimit(int uploadVisitorLimit) {
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
}
public int getUploadShareLimit() {
return uploadShareLimit;
}
public void setUploadShareLimit(int uploadShareLimit) {
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
}
public Duration getActiveLease() {
return activeLease;
}
public void setActiveLease(Duration activeLease) {
this.activeLease = requirePositive(activeLease, "activeLease");
}
public Duration getUploadGrantTtl() {
return uploadGrantTtl;
}
public void setUploadGrantTtl(Duration uploadGrantTtl) {
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
}
private static int requirePositive(int value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
private static Duration requirePositive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
}

View File

@@ -3,17 +3,50 @@ package tech.easyflow.admin.controller.ai;
import jakarta.servlet.http.HttpServletRequest;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Locale;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowShareController} 分享地址构建测试。
*/
public class WorkflowShareControllerTest {
/**
* 验证分享解析仅依赖分享密钥,不读取当前浏览器登录租户。
*/
@Test
public void shouldResolvePublicChatShareWithoutLoginContext()
throws Exception {
WorkflowShareService shareService = mock(WorkflowShareService.class);
WorkflowShare share = new WorkflowShare();
share.setWorkflowId(BigInteger.valueOf(11));
when(shareService.resolvePublicChatShare("share-key"))
.thenReturn(share);
WorkflowShareController controller = new WorkflowShareController();
setField(controller, "workflowShareService", shareService);
BigInteger workflowId = controller.resolveUrlShare(request(Map.of(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
.toLowerCase(Locale.ROOT),
"share-key"
))).getData().get("workflowId");
Assert.assertEquals(workflowId, BigInteger.valueOf(11));
verify(shareService).resolvePublicChatShare("share-key");
}
/**
* 验证分享地址保留前端部署基路径。
*
@@ -120,4 +153,11 @@ public class WorkflowShareControllerTest {
}
return 0D;
}
private void setField(Object target, String name, Object value)
throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -4,13 +4,19 @@ import com.easyagents.flow.core.chain.ChainConsts;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.testng.Assert;
import org.testng.annotations.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -69,4 +75,81 @@ public class WorkflowChatEventStreamTest {
WorkflowChatEventStream.visibleFinalOutput(null).isEmpty()
);
}
/**
* 验证工作流启动异常也会释放匿名活动执行租约。
*/
@Test
public void shouldCleanupExternalResourceWhenStartFails() {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
doThrow(new IllegalStateException("start failed"))
.when(chainExecutor)
.executeAsync(any(), any(), any());
WorkflowChatEventStream eventStream =
new WorkflowChatEventStream(chainExecutor);
AtomicInteger cleanupCount = new AtomicInteger();
Assert.expectThrows(
IllegalStateException.class,
() -> eventStream.start(
"definition",
Map.of(),
cleanupCount::incrementAndGet
)
);
Assert.assertEquals(cleanupCount.get(), 1);
}
/**
* 验证浏览器断开只分离 SSE不取消仍在运行的工作流。
*/
@Test
public void shouldKeepRuntimeRunningWhenBrowserDisconnects() {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
Consumer<String> beforeStart = invocation.getArgument(2);
beforeStart.accept("execution-1");
return "execution-1";
}).when(chainExecutor).executeAsync(any(), any(), any());
CapturingSseEmitter emitter = new CapturingSseEmitter();
WorkflowChatEventStream eventStream =
new WorkflowChatEventStream(chainExecutor) {
@Override
SseEmitter createEmitter() {
return emitter;
}
};
AtomicInteger cleanupCount = new AtomicInteger();
eventStream.start(
"definition",
Map.of(),
cleanupCount::incrementAndGet,
Duration.ofMinutes(35)
);
emitter.disconnect();
verify(chainExecutor, never()).cancel(any(), any());
Assert.assertEquals(cleanupCount.get(), 0);
eventStream.shutdown();
Assert.assertEquals(cleanupCount.get(), 1);
}
private static final class CapturingSseEmitter extends SseEmitter {
private Runnable completion;
@Override
public synchronized void onCompletion(Runnable callback) {
this.completion = callback;
}
private void disconnect() {
Assert.assertNotNull(completion);
completion.run();
}
}
}

View File

@@ -0,0 +1,86 @@
package tech.easyflow.admin.service.ai;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatAccessGuard} Redis 失败关闭测试。
*/
public class WorkflowPublicChatAccessGuardTest {
@Test
public void shouldExposeDocumentedProtectionDefaults() {
WorkflowPublicShareProperties properties =
new WorkflowPublicShareProperties();
Assert.assertEquals(properties.getRunVisitorLimit(), 5);
Assert.assertEquals(properties.getRunShareLimit(), 60);
Assert.assertEquals(properties.getUploadVisitorLimit(), 10);
Assert.assertEquals(properties.getUploadShareLimit(), 60);
Assert.assertEquals(properties.getRateWindow(), Duration.ofMinutes(1));
Assert.assertEquals(properties.getActiveLease(), Duration.ofMinutes(35));
}
@Test
public void shouldReturn429WhenFixedWindowIsExceeded() {
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
when(redisTemplate.execute(
any(DefaultRedisScript.class),
anyList(),
anyString(),
anyString(),
anyString()
)).thenReturn(0L);
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
redisTemplate,
lockExecutor,
new WorkflowPublicShareProperties()
);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> guard.checkRun(BigInteger.ONE, "visitor")
);
Assert.assertEquals(error.getHttpStatus(), 429);
}
@Test
public void shouldReturn503WhenRedisRateLimitIsUnavailable() {
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
when(redisTemplate.execute(
any(DefaultRedisScript.class),
anyList(),
anyString(),
anyString(),
anyString()
)).thenThrow(new IllegalStateException("redis unavailable"));
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
redisTemplate,
lockExecutor,
new WorkflowPublicShareProperties()
);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> guard.checkUpload(BigInteger.ONE, "visitor")
);
Assert.assertEquals(error.getHttpStatus(), 503);
}
}

View File

@@ -0,0 +1,136 @@
package tech.easyflow.admin.service.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatContextResolver} 匿名主体边界测试。
*/
public class WorkflowPublicChatContextResolverTest {
@Test
public void shouldUseCurrentShareCreatorAsPermissionSubject() {
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
WorkflowPublicChatContext context = fixture.resolver.resolveActive(
"share-key",
"00112233445566778899aabbccddeeff"
);
Assert.assertEquals(context.creator().getId(), BigInteger.TEN);
Assert.assertEquals(context.creator().getTenantId(), BigInteger.ONE);
Assert.assertEquals(
context.visitorDigest(),
WorkflowSharePolicy.hashChatVisitor(
"share-key",
"00112233445566778899aabbccddeeff"
)
);
}
@Test
public void shouldRejectDisabledShareCreator() {
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.resolver.resolveActive(
"share-key",
"00112233445566778899aabbccddeeff"
)
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertTrue(error.getMessage().contains("创建者账号"));
}
@Test
public void shouldResolveHistoricalShareWithoutCurrentCreatorCheck() {
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
WorkflowPublicChatContext context = fixture.resolver.resolveHistorical(
"share-key",
"00112233445566778899aabbccddeeff"
);
Assert.assertNull(context.creator());
Assert.assertNull(context.workflow());
verify(fixture.accountService, never()).getById(BigInteger.TEN);
}
@Test
public void shouldRejectMalformedVisitorIdentity() {
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.resolver.resolveActive("share-key", "short")
);
Assert.assertEquals(error.getErrorCode(), 40031);
}
private Fixture fixture(Integer accountStatus) {
WorkflowShareService shareService = mock(WorkflowShareService.class);
WorkflowService workflowService = mock(WorkflowService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowShare share = new WorkflowShare();
share.setId(BigInteger.valueOf(7));
share.setWorkflowId(BigInteger.valueOf(11));
share.setTenantId(BigInteger.ONE);
share.setCreatedBy(BigInteger.TEN);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(11));
workflow.setTenantId(BigInteger.ONE);
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
workflow.setPublishedSnapshotJson(Map.of("content", "{}"));
SysAccount account = new SysAccount();
account.setId(BigInteger.TEN);
account.setTenantId(BigInteger.ONE);
account.setStatus(accountStatus);
when(shareService.resolvePublicChatShare("share-key"))
.thenReturn(share);
when(shareService.resolveHistoricalChatShare("share-key"))
.thenReturn(share);
when(workflowService.getPublishedById(BigInteger.valueOf(11)))
.thenReturn(workflow);
when(accountService.getById(BigInteger.TEN)).thenReturn(account);
return new Fixture(
new WorkflowPublicChatContextResolver(
shareService,
workflowService,
accountService
),
accountService
);
}
private record Fixture(
WorkflowPublicChatContextResolver resolver,
SysAccountService accountService
) {
}
}

View File

@@ -0,0 +1,229 @@
package tech.easyflow.admin.service.ai;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatService} 匿名执行归属测试。
*/
public class WorkflowPublicChatServiceTest {
@Test
public void shouldSeparatePermissionSubjectFromExecutionOwner() {
Fixture fixture = fixture();
RedisLockExecutor.LockHandle activity = mock(
RedisLockExecutor.LockHandle.class);
when(fixture.parameterResolver.normalizeRuntimeVariables(
eq("{}"), anyMap())).thenReturn(new LinkedHashMap<>());
when(fixture.accessGuard.acquireActivity(
BigInteger.valueOf(7), "visitor-digest"))
.thenReturn(activity);
when(fixture.accessGuard.activityLease())
.thenReturn(Duration.ofMinutes(35));
when(fixture.eventStream.start(
eq(PublishedWorkflowDefinitionIds.published("11")),
anyMap(),
any(Runnable.class),
eq(Duration.ofMinutes(35))
)).thenReturn(new SseEmitter());
fixture.service.run("share-key", visitorId(), Map.of());
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> variables = ArgumentCaptor
.forClass((Class) Map.class);
verify(fixture.eventStream).start(
eq(PublishedWorkflowDefinitionIds.published("11")),
variables.capture(),
any(Runnable.class),
eq(Duration.ofMinutes(35))
);
Assert.assertSame(
variables.getValue().get(Constants.LOGIN_USER_KEY),
fixture.context.creator()
);
Assert.assertEquals(
variables.getValue().get(WorkFlowUtil.CREATED_KEY_MEMORY_KEY),
"WORKFLOW_CHAT_SHARE:7"
);
Assert.assertEquals(
variables.getValue().get(WorkFlowUtil.CREATED_BY_MEMORY_KEY),
"visitor-digest"
);
}
@Test
public void shouldRejectExecutionOwnedByAnotherVisitor() {
Fixture fixture = fixture();
WorkflowExecResult record = new WorkflowExecResult();
record.setWorkflowId(BigInteger.valueOf(11));
record.setCreatedKey("WORKFLOW_CHAT_SHARE:7");
record.setCreatedBy("another-visitor");
when(fixture.execResultService.getByExecKey("execution-1"))
.thenReturn(record);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.detail(
"share-key", visitorId(), "execution-1")
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 40333);
}
@Test
public void shouldExposeMinimalRuntimeStateForRefreshRecovery() {
Fixture fixture = fixture();
WorkflowExecResult record = ownedRecord();
when(fixture.execResultService.getByExecKey("execution-1"))
.thenReturn(record);
when(fixture.execStepService.list(any(QueryWrapper.class)))
.thenReturn(List.of());
ChainStateRepository repository = mock(ChainStateRepository.class);
ChainState state = new ChainState();
state.setStatus(ChainStatus.SUSPEND);
state.setMessage("请确认是否继续");
state.setSuspendForParameters(List.of(new Parameter("approved")));
when(fixture.chainExecutor.getChainStateRepository())
.thenReturn(repository);
when(repository.load("execution-1")).thenReturn(state);
Map<String, Object> detail = fixture.service.detail(
"share-key", visitorId(), "execution-1");
@SuppressWarnings("unchecked")
Map<String, Object> runtime =
(Map<String, Object>) detail.get("runtime");
Assert.assertEquals(runtime.get("status"), "SUSPEND");
Assert.assertEquals(runtime.get("statusValue"), 5);
Assert.assertEquals(runtime.get("message"), "请确认是否继续");
Assert.assertEquals(
((List<?>) runtime.get("parameters")).size(),
1
);
}
private WorkflowExecResult ownedRecord() {
WorkflowExecResult record = new WorkflowExecResult();
record.setId(BigInteger.valueOf(31));
record.setWorkflowId(BigInteger.valueOf(11));
record.setExecKey("execution-1");
record.setCreatedKey("WORKFLOW_CHAT_SHARE:7");
record.setCreatedBy("visitor-digest");
return record;
}
private Fixture fixture() {
WorkflowPublicChatContextResolver contextResolver = mock(
WorkflowPublicChatContextResolver.class);
WorkflowCheckService workflowCheckService = mock(
WorkflowCheckService.class);
WorkflowRunningParameterResolver parameterResolver = mock(
WorkflowRunningParameterResolver.class);
WorkflowPublicChatUploadService uploadService = mock(
WorkflowPublicChatUploadService.class);
WorkflowPublicChatAccessGuard accessGuard = mock(
WorkflowPublicChatAccessGuard.class);
WorkflowChatEventStream eventStream = mock(
WorkflowChatEventStream.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
WorkflowExecResultService execResultService = mock(
WorkflowExecResultService.class);
WorkflowExecStepService execStepService = mock(
WorkflowExecStepService.class);
WorkflowShare share = new WorkflowShare();
share.setId(BigInteger.valueOf(7));
share.setWorkflowId(BigInteger.valueOf(11));
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(11));
workflow.setContent("{}");
LoginAccount creator = new LoginAccount();
creator.setId(BigInteger.TEN);
creator.setTenantId(BigInteger.ONE);
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
share,
workflow,
creator,
"share-key",
"visitor-digest"
);
when(contextResolver.resolveActive("share-key", visitorId()))
.thenReturn(context);
when(contextResolver.resolveHistorical("share-key", visitorId()))
.thenReturn(context);
WorkflowPublicChatService service = new WorkflowPublicChatService(
contextResolver,
workflowCheckService,
parameterResolver,
uploadService,
accessGuard,
eventStream,
chainExecutor,
execResultService,
execStepService
);
return new Fixture(
service,
context,
parameterResolver,
accessGuard,
eventStream,
chainExecutor,
execResultService,
execStepService
);
}
private String visitorId() {
return "00112233445566778899aabbccddeeff";
}
private record Fixture(
WorkflowPublicChatService service,
WorkflowPublicChatContext context,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
}
}

View File

@@ -0,0 +1,168 @@
package tech.easyflow.admin.service.ai;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.multipart.MultipartFile;
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.entity.WorkflowShare;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatUploadService} 上传边界测试。
*/
public class WorkflowPublicChatUploadServiceTest {
@Test
public void shouldStoreDeclaredFileUnderVisitorScope() {
Fixture fixture = fixture("file");
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getOriginalFilename()).thenReturn("input.pdf");
when(fixture.storageService.save(
eq(file), anyString())).thenReturn("/files/input.pdf");
fixture.service.upload(fixture.context, "attachment", file);
verify(fixture.accessGuard).checkUpload(
BigInteger.valueOf(7), "visitor-digest");
verify(fixture.storageService).save(
file,
"workflow-chat-share/7/visitor-digest"
);
}
@Test
public void shouldRejectReferenceWithoutCurrentVisitorGrant() {
Fixture fixture = fixture("file");
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.assertOwnedUploads(
fixture.context,
Map.of("attachment", List.of(Map.of(
"fileName", "input.pdf",
"filePath", "/files/other.pdf"
)))
)
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 40332);
}
@Test
public void shouldRejectGrantCreatedForDifferentParameterType() {
Fixture fixture = fixture("image");
when(fixture.valueOperations.get(anyString())).thenReturn("file");
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.assertOwnedUploads(
fixture.context,
Map.of("attachment", Map.of(
"sourceType", "upload",
"filePath", "/files/input.png"
))
)
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 40332);
}
@Test
public void shouldRejectUnsupportedImageType() {
Fixture fixture = fixture("image");
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getContentType()).thenReturn("image/svg+xml");
when(file.getOriginalFilename()).thenReturn("input.svg");
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.upload(
fixture.context, "attachment", file)
);
Assert.assertTrue(error.getMessage().contains("PNG"));
}
@SuppressWarnings("unchecked")
private Fixture fixture(String contentType) {
WorkflowRunningParameterResolver parameterResolver = mock(
WorkflowRunningParameterResolver.class);
WorkflowPublicChatAccessGuard accessGuard = mock(
WorkflowPublicChatAccessGuard.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(
ValueOperations.class);
FileStorageService storageService = mock(FileStorageService.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(11));
when(parameterResolver.buildRunningParametersView(workflow))
.thenReturn(Map.of(
"startFormSchema",
List.of(Map.of(
"key", "attachment",
"contentType", contentType
))
));
WorkflowShare share = new WorkflowShare();
share.setId(BigInteger.valueOf(7));
share.setExpiresAt(new Date(
System.currentTimeMillis() + 60_000L));
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
share,
workflow,
null,
"share-key",
"visitor-digest"
);
WorkflowPublicChatUploadService service =
new WorkflowPublicChatUploadService(
parameterResolver,
accessGuard,
new WorkflowPublicShareProperties(),
redisTemplate,
storageService
);
return new Fixture(
service,
context,
accessGuard,
redisTemplate,
valueOperations,
storageService
);
}
private record Fixture(
WorkflowPublicChatUploadService service,
WorkflowPublicChatContext context,
WorkflowPublicChatAccessGuard accessGuard,
StringRedisTemplate redisTemplate,
ValueOperations<String, String> valueOperations,
FileStorageService storageService
) {
}
}

View File

@@ -5,9 +5,12 @@ import tech.easyflow.common.annotation.DictDef;
@DictDef(name = "任务执行结果", code = "jobResult", keyField = "code", labelField = "text")
public enum EnumJobResult {
SUCCESS(1,"成功"),
FAIL(0,"失败"),
PENDING(2,"等待执行"),
RUNNING(3,"执行中"),
DEAD(4,"需人工处理"),
CANCELLED(5,"已取消"),
;
private final int code;

View File

@@ -5,10 +5,8 @@ import tech.easyflow.common.annotation.DictDef;
@DictDef(name = "错过策略", code = "misfirePolicy", keyField = "code", labelField = "text")
public enum EnumMisfirePolicy {
DEFAULT(0,"默认"),
MISFIRE_IGNORE_MISFIRES(1,"立即触发"),
MISFIRE_FIRE_AND_PROCEED(2,"立即触发一次"),
MISFIRE_DO_NOTHING(3,"忽略");
FIRE_ONCE_NOW(2,"恢复后补执行一次"),
SKIP(3,"跳过本次");
;
private final int code;

View File

@@ -1,5 +1,6 @@
package tech.easyflow.common.cache;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -10,6 +11,11 @@ import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/**
@@ -27,6 +33,13 @@ public class RedisLockExecutor {
private static final DefaultRedisScript<Long> NEXT_FENCING_TOKEN_SCRIPT;
private static final DefaultRedisScript<Long> ACQUIRE_FENCED_LOCK_SCRIPT;
private final ScheduledExecutorService lockRenewalExecutor =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "easyflow-redis-lock-renewal");
thread.setDaemon(true);
return thread;
});
static {
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
RELEASE_LOCK_SCRIPT.setScriptText(
@@ -94,6 +107,66 @@ public class RedisLockExecutor {
}
}
/**
* 在自动续租的分布式锁保护下执行任务。
*
* <p>适用于包含数据库锁等待或外部持久化操作、无法由固定租约严格覆盖的管理命令。
* 若执行期间确认锁已丢失,则不向调用方返回成功。</p>
*/
public void executeWithRenewingLock(
String lockKey,
Duration waitTimeout,
Duration leaseTimeout,
Runnable task) {
executeWithRenewingLock(lockKey, waitTimeout, leaseTimeout, () -> {
task.run();
return null;
});
}
/**
* 在自动续租的分布式锁保护下执行有返回值任务。
*/
public <T> T executeWithRenewingLock(
String lockKey,
Duration waitTimeout,
Duration leaseTimeout,
Supplier<T> task) {
LockHandle handle = acquire(lockKey, waitTimeout, leaseTimeout);
AtomicBoolean lost = new AtomicBoolean();
long renewalIntervalMillis = Math.max(1L, leaseTimeout.toMillis() / 3L);
ScheduledFuture<?> renewal = lockRenewalExecutor.scheduleWithFixedDelay(
() -> {
try {
if (!handle.renew()) {
lost.set(true);
}
} catch (RuntimeException exception) {
lost.set(true);
log.warn("分布式锁续租失败,当前命令不得返回成功: lockKey={}",
lockKey, exception);
}
},
renewalIntervalMillis,
renewalIntervalMillis,
TimeUnit.MILLISECONDS);
try {
T result = task.get();
if (lost.get()) {
throw new IllegalStateException("执行期间分布式锁已丢失lockKey=" + lockKey);
}
return result;
} finally {
renewal.cancel(false);
handle.release();
}
}
@PreDestroy
public void shutdownLockRenewalExecutor() {
lockRenewalExecutor.shutdownNow();
}
/**
* 获取显式释放的分布式锁句柄。
*

View File

@@ -11,6 +11,9 @@ import org.springframework.data.redis.core.script.RedisScript;
import java.lang.reflect.Field;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link RedisLockExecutor} 回归测试。
@@ -147,6 +150,96 @@ public class RedisLockExecutorTest {
String.valueOf(Duration.ofDays(4).toMillis())));
}
@Test
public void renewingLockShouldRenewBeforeLongRunningCommandCompletes() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
CountDownLatch renewed = new CountDownLatch(1);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenAnswer(invocation -> {
renewed.countDown();
return 1L;
});
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
try {
executor.executeWithRenewingLock(
"easyflow:test:renewing-lock",
Duration.ZERO,
Duration.ofMillis(60),
() -> {
try {
Assert.assertTrue(renewed.await(1, TimeUnit.SECONDS));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("等待锁续租时被中断", exception);
}
});
} finally {
executor.shutdownLockRenewalExecutor();
}
Mockito.verify(redisTemplate, Mockito.atLeastOnce()).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.eq(List.of("easyflow:test:renewing-lock")),
ArgumentMatchers.anyString(),
ArgumentMatchers.eq("60"));
}
@Test
public void renewingLockMustNotReturnSuccessAfterRenewalThrows() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
CountDownLatch renewalAttempted = new CountDownLatch(1);
AtomicInteger scriptCalls = new AtomicInteger();
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenAnswer(invocation -> {
if (scriptCalls.incrementAndGet() == 1) {
renewalAttempted.countDown();
throw new IllegalStateException("redis unavailable");
}
return 1L;
});
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
try {
try {
executor.executeWithRenewingLock(
"easyflow:test:renewal-failure",
Duration.ZERO,
Duration.ofMillis(60),
() -> {
try {
Assert.assertTrue(renewalAttempted.await(1, TimeUnit.SECONDS));
// 等待续租线程把失败结果发布到调用线程;业务任务与续租
// 同时完成时,锁仍处于原租约内且 callback 已结束。
Thread.sleep(50L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(exception);
}
});
Assert.fail("续租异常后不应返回成功");
} catch (IllegalStateException exception) {
Assert.assertTrue(exception.getMessage().contains("分布式锁已丢失"));
}
} finally {
executor.shutdownLockRenewalExecutor();
}
}
@SuppressWarnings("unchecked")
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);

View File

@@ -85,7 +85,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
record.setStartTime(new Date());
record.setStatus(state.getStatus().getValue());
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
record.setCreatedBy(WorkFlowUtil.getCreatedBy(chain));
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
try {
workflowExecResultService.save(record);

View File

@@ -59,6 +59,8 @@ public class WorkflowCheckService {
private static final String SYSTEM_START_PARAM_NAME = "user_input";
private static final int MIN_LOOP_COUNT = 1;
private static final int MAX_LOOP_COUNT = 300;
private static final String JOIN_MODE_ANY = "any";
private static final String JOIN_MODE_ALL = "all";
@Resource
private WorkflowService workflowService;
@@ -196,6 +198,10 @@ public class WorkflowCheckService {
edge.id = trimToNull(edgeJson.getString("id"));
edge.source = trimToNull(edgeJson.getString("source"));
edge.target = trimToNull(edgeJson.getString("target"));
JSONObject edgeData = edgeJson.getJSONObject("data");
edge.condition = edgeData == null
? null
: trimToNull(edgeData.getString("condition"));
if (!StringUtils.hasText(edge.id)) {
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
@@ -228,10 +234,162 @@ public class WorkflowCheckService {
parsedWorkflow.nodes = nodes;
parsedWorkflow.edges = edges;
parsedWorkflow.nodeMap = nodeMap;
checkJoinModes(parsedWorkflow, issues, issueKeys);
checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
return parsedWorkflow;
}
/**
* 校验节点汇聚模式及其静态可证明的到达安全性。
*
* @param parsed 工作流视图
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkJoinModes(
ParsedWorkflow parsed,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
Map<String, List<EdgeView>> inwardEdges = new LinkedHashMap<>();
for (EdgeView edge : parsed.edges) {
if (edge == null || !StringUtils.hasText(edge.target)) {
continue;
}
inwardEdges.computeIfAbsent(
edge.target, ignored -> new ArrayList<>()).add(edge);
}
for (NodeView node : parsed.nodes) {
String joinMode = resolveJoinMode(node);
if (joinMode == null) {
addIssue(
issues,
issueKeys,
"JOIN_MODE_INVALID",
"执行时机配置无效joinMode 仅支持 any 或 all",
node.id,
null,
node.name);
continue;
}
if (JOIN_MODE_ALL.equals(joinMode)
&& StringUtils.hasText(node.parentId)) {
addIssue(
issues,
issueKeys,
"JOIN_MODE_LOOP_CHILD_UNSUPPORTED",
"显式循环子图暂不支持“全部上游完成”,请改为“任一上游完成”",
node.id,
null,
node.name);
}
}
Set<String> guaranteedNodes = findGuaranteedNodes(
parsed, inwardEdges);
for (NodeView node : parsed.nodes) {
if (!JOIN_MODE_ALL.equals(resolveJoinMode(node))
|| StringUtils.hasText(node.parentId)) {
continue;
}
List<EdgeView> directInward = inwardEdges.getOrDefault(
node.id, Collections.emptyList());
if (directInward.size() <= 1) {
continue;
}
boolean allGuaranteed = directInward.stream().allMatch(edge ->
!edge.hasCondition()
&& guaranteedNodes.contains(edge.source));
if (!allGuaranteed) {
addIssue(
issues,
issueKeys,
"JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED",
"“全部上游完成”可能永久等待:存在条件、互斥或无法证明必达的上游路径。"
+ "请改为“任一上游完成”或调整连线,确保所有直接入边都会到达",
node.id,
null,
node.name);
}
}
}
/**
* 使用保守固定点传播计算能够保证执行的根级节点。
*
* @param parsed 工作流视图
* @param inwardEdges 直接入边索引
* @return 保证执行的节点 ID
*/
private Set<String> findGuaranteedNodes(
ParsedWorkflow parsed,
Map<String, List<EdgeView>> inwardEdges) {
Set<String> guaranteed = parsed.nodes.stream()
.filter(NodeView::isRootLevel)
.filter(node -> TYPE_START.equals(node.type))
.map(node -> node.id)
.filter(StringUtils::hasText)
.collect(Collectors.toCollection(LinkedHashSet::new));
boolean changed;
do {
changed = false;
for (NodeView node : parsed.nodes) {
if (!node.isRootLevel()
|| guaranteed.contains(node.id)
|| hasAdvancedCondition(node)) {
continue;
}
String joinMode = resolveJoinMode(node);
if (joinMode == null) {
continue;
}
List<EdgeView> directInward = inwardEdges.getOrDefault(
node.id, Collections.emptyList());
boolean isGuaranteed;
if (JOIN_MODE_ALL.equals(joinMode)) {
isGuaranteed = !directInward.isEmpty()
&& directInward.stream().allMatch(edge ->
!edge.hasCondition()
&& guaranteed.contains(edge.source));
} else {
isGuaranteed = directInward.stream().anyMatch(edge ->
!edge.hasCondition()
&& guaranteed.contains(edge.source));
}
if (isGuaranteed && guaranteed.add(node.id)) {
changed = true;
}
}
} while (changed);
return guaranteed;
}
/**
* 读取节点汇聚模式。字段缺失时兼容为 any显式非法值返回 null。
*/
private String resolveJoinMode(NodeView node) {
if (node == null || node.data == null
|| !node.data.containsKey("joinMode")) {
return JOIN_MODE_ANY;
}
String value = trimToNull(node.data.getString("joinMode"));
if (JOIN_MODE_ANY.equalsIgnoreCase(value)) {
return JOIN_MODE_ANY;
}
if (JOIN_MODE_ALL.equalsIgnoreCase(value)) {
return JOIN_MODE_ALL;
}
return null;
}
private boolean hasAdvancedCondition(NodeView node) {
return node != null
&& node.data != null
&& StringUtils.hasText(
trimToNull(node.data.getString("condition")));
}
/**
* 校验普通循环、显式循环和循环父子层级。
*
@@ -1610,5 +1768,10 @@ public class WorkflowCheckService {
private String id;
private String source;
private String target;
private String condition;
private boolean hasCondition() {
return StringUtils.hasText(condition);
}
}
}

View File

@@ -139,7 +139,7 @@ public class WorkflowRunningParameterResolver {
}
/**
* 归一化工作流运行时变量,确保文件参数统一文件对象数组
* 归一化工作流运行时变量,统一文件结构并移除空图片值
*
* @param content 工作流内容
* @param variables 原始运行变量
@@ -162,7 +162,13 @@ public class WorkflowRunningParameterResolver {
if (isFileParameter(parameter)) {
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
} else if (isImageParameter(parameter)) {
normalized.put(name, normalizeImageVariableValue(normalized.get(name), name));
Object imageValue = normalizeImageVariableValue(
normalized.get(name), name);
if (imageValue == null) {
normalized.remove(name);
} else {
normalized.put(name, imageValue);
}
}
}
return normalized;
@@ -683,6 +689,10 @@ public class WorkflowRunningParameterResolver {
if (value == null) {
return;
}
if (value instanceof String stringValue
&& !StringUtils.hasText(stringValue)) {
return;
}
if (value instanceof Collection<?> collection) {
for (Object item : collection) {
collectFileValues(item, result);

View File

@@ -92,4 +92,22 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
* @return 有效对话分享记录
*/
WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId);
/**
* 跨租户解析当前有效的匿名对话分享。
*
* @param shareKey 原始分享密钥
* @return 有效且指向严格发布工作流的分享记录
*/
WorkflowShare resolvePublicChatShare(String shareKey);
/**
* 跨租户解析匿名对话分享的历史记录。
*
* <p>仅用于详情和取消已发起执行,不校验分享状态、有效期与当前发布态。</p>
*
* @param shareKey 原始分享密钥
* @return 对话分享记录
*/
WorkflowShare resolveHistoricalChatShare(String shareKey);
}

View File

@@ -2,7 +2,7 @@ package tech.easyflow.ai.service;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
@@ -15,7 +15,7 @@ import java.util.Objects;
/**
* 工作流使用权限校验服务。
*
* <p>统一封装工作流存在性、租户、启用状态和资源使用权限校验,供页面能力和后台任务复用。</p>
* <p>统一封装工作流存在性、租户、发布快照和资源使用权限校验,供页面能力和后台任务复用。</p>
*/
@Service
public class WorkflowUsageAuthorizationService {
@@ -40,20 +40,20 @@ public class WorkflowUsageAuthorizationService {
}
/**
* 获取当前账号可使用的启用工作流。
* 获取当前账号可使用的已发布工作流视图
*
* @param workflowId 工作流 ID
* @param account 使用工作流的账号
* @param denyMessage 校验失败提示
* @return 可使用的工作流
* @throws BusinessException 工作流不存在、未启用、跨租户或无使用权限时抛出
* @return 可使用的工作流发布视图
* @throws BusinessException 工作流不存在、未发布、缺少发布快照、跨租户或无使用权限时抛出
*/
public Workflow requireUsableWorkflow(
BigInteger workflowId,
LoginAccount account,
String denyMessage) {
String message = denyMessage == null || denyMessage.isBlank()
? "工作流不存在、已禁用或无权使用"
? "工作流不存在、未发布或无权使用"
: denyMessage;
if (workflowId == null || account == null || account.getId() == null
|| account.getTenantId() == null) {
@@ -62,7 +62,9 @@ public class WorkflowUsageAuthorizationService {
Workflow workflow = workflowService.getById(workflowId);
boolean usable = workflow != null
&& Objects.equals(workflow.getTenantId(), account.getTenantId())
&& EnumDataStatus.AVAILABLE.getCode().equals(workflow.getStatus())
&& PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty()
&& resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
@@ -71,6 +73,10 @@ public class WorkflowUsageAuthorizationService {
if (!usable) {
throw new BusinessException(403, 403, message);
}
return workflow;
Workflow published = workflowService.toPublishedView(workflow);
if (published == null) {
throw new BusinessException(403, 403, message);
}
return published;
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
@@ -23,6 +24,7 @@ import java.math.BigInteger;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
/**
@@ -184,6 +186,61 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.CHAT);
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowShare resolvePublicChatShare(String shareKey) {
if (shareKey == null || shareKey.isBlank()) {
throw invalidShare();
}
return TenantManager.withoutTenantCondition(() -> {
WorkflowShare share = findShare(
shareKey,
WorkflowSharePurpose.CHAT,
true
);
if (share == null) {
throw invalidShare();
}
if (share.getExpiresAt() == null
|| !share.getExpiresAt().after(new Date())) {
throw new BusinessException(403, 403, "工作流分享链接已过期");
}
Workflow workflow = workflowService.getPublishedById(
share.getWorkflowId());
if (workflow == null
|| !Objects.equals(
share.getTenantId(), workflow.getTenantId())) {
throw invalidShare();
}
if (!isStrictlyPublished(workflow)) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
return share;
});
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowShare resolveHistoricalChatShare(String shareKey) {
if (shareKey == null || shareKey.isBlank()) {
throw invalidShare();
}
WorkflowShare share = TenantManager.withoutTenantCondition(
() -> findShare(
shareKey,
WorkflowSharePurpose.CHAT,
false
));
if (share == null) {
throw invalidShare();
}
return share;
}
/**
* 按用途校验并解析分享。
*
@@ -200,10 +257,7 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
if (shareKey == null || shareKey.isBlank() || tenantId == null) {
throw invalidShare();
}
WorkflowShare share = getOne(QueryWrapper.create()
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
.eq(WorkflowShare::getSharePurpose, purpose.name())
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
WorkflowShare share = findShare(shareKey, purpose, true);
if (share == null || !tenantId.equals(share.getTenantId())) {
throw invalidShare();
}
@@ -220,6 +274,34 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
return share;
}
/**
* 按密钥与用途查询分享记录。
*
* @param shareKey 原始分享密钥
* @param purpose 分享用途
* @param activeOnly 是否仅查询启用记录
* @return 分享记录
*/
private WorkflowShare findShare(
String shareKey,
WorkflowSharePurpose purpose,
boolean activeOnly
) {
QueryWrapper query = QueryWrapper.create()
.eq(
WorkflowShare::getShareKeyHash,
WorkflowSharePolicy.hashShareKey(shareKey)
)
.eq(WorkflowShare::getSharePurpose, purpose.name());
if (activeOnly) {
query.eq(
WorkflowShare::getStatus,
KnowledgeShareStatus.ENABLED.name()
);
}
return getOne(query);
}
/**
* 在锁保护下创建或替换工作流的唯一分享记录。
*

View File

@@ -9,6 +9,8 @@ import java.time.Duration;
import java.util.Date;
import java.util.HexFormat;
import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* 工作流协作分享的密钥、时效与接口授权策略。
@@ -25,6 +27,11 @@ public final class WorkflowSharePolicy {
*/
public static final String CHAT_SHARE_KEY_HEADER = "X-Workflow-Chat-Share-Key";
/**
* 工作流对话分享访客标识请求头。
*/
public static final String CHAT_VISITOR_HEADER = "X-Workflow-Chat-Visitor";
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
private static final Duration DEFAULT_CHAT_EXPIRE_DURATION = Duration.ofDays(7);
private static final Set<String> ALLOWED_REQUESTS = Set.of(
@@ -67,6 +74,28 @@ public final class WorkflowSharePolicy {
}
}
/**
* 计算匿名访客的不可逆执行归属摘要。
*
* @param shareKey 原始分享密钥
* @param visitorId 当前标签页访客标识
* @return HMAC-SHA256 前 16 字节的小写十六进制摘要
*/
public static String hashChatVisitor(String shareKey, String visitorId) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
shareKey.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
));
byte[] digest = mac.doFinal(
visitorId.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 16);
} catch (Exception exception) {
throw new IllegalStateException("HmacSHA256 unavailable", exception);
}
}
/**
* 计算默认过期时间。
*

View File

@@ -19,6 +19,7 @@ public class WorkFlowUtil {
public final static String WORKFLOW_CHAT_SHARE = "WORKFLOW_CHAT_SHARE";
public final static String WORKFLOW_KEY = "workflow";
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
public final static String CREATED_BY_MEMORY_KEY = "workflowCreatedBy";
public static String removeSensitiveInfo(String originJson) {
JSONObject workflowInfo = JSON.parseObject(originJson);
@@ -56,6 +57,35 @@ public class WorkFlowUtil {
return value == null ? USER_KEY : String.valueOf(value);
}
/**
* 获取工作流执行记录的归属主体。
*
* <p>匿名分享可覆盖为访客摘要;其他入口继续使用权限主体账号 ID。</p>
*
* @param chain 当前工作流执行链
* @return 执行归属主体
*/
public static String getCreatedBy(Chain chain) {
Object value = chain.getExecutionState()
.getMemory()
.get(CREATED_BY_MEMORY_KEY);
if (value != null) {
return String.valueOf(value);
}
LoginAccount operator = getOperator(chain);
return operator.getId() == null ? "0" : operator.getId().toString();
}
/**
* 构建工作流匿名分享的执行来源标识。
*
* @param shareId 分享记录 ID
* @return 执行来源标识
*/
public static String publicChatShareCreatedKey(BigInteger shareId) {
return WORKFLOW_CHAT_SHARE + ":" + shareId;
}
public static LoginAccount defaultAccount() {
LoginAccount account = new LoginAccount();
account.setId(new BigInteger("0"));

View File

@@ -22,6 +22,163 @@ import java.util.Map;
public class WorkflowCheckServiceTest {
@Test
public void testSaveAndPreExecuteShouldPassGuaranteedAllJoin() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, data("分支 A")),
node("b", "codeNode", null, data("分支 B")),
node("join", "codeNode", null, joinData),
node("end", "endNode", null, data("结束"))),
array(
edge("start-a", "start", "a"),
edge("start-b", "start", "b"),
edge("a-join", "a", "join"),
edge("b-join", "b", "join"),
edge("join-end", "join", "end")));
Assert.assertTrue(service.checkContent(
content, WorkflowCheckStage.SAVE, null).isPassed());
Assert.assertTrue(service.checkContent(
content, WorkflowCheckStage.PRE_EXECUTE, null).isPassed());
}
@Test
public void testSaveAndPreExecuteShouldBlockConditionalAllJoin() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, data("条件来源")),
node("b", "codeNode", null, data("普通来源")),
node("join", "codeNode", null, joinData),
node("end", "endNode", null, data("结束"))),
array(
conditionalEdge("start-a", "start", "a", "enabled === true"),
edge("start-b", "start", "b"),
edge("a-join", "a", "join"),
edge("b-join", "b", "join"),
edge("join-end", "join", "end")));
WorkflowCheckResult save = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
WorkflowCheckResult preExecute = service.checkContent(
content, WorkflowCheckStage.PRE_EXECUTE, null);
Assert.assertFalse(save.isPassed());
Assert.assertFalse(preExecute.isPassed());
assertHasCode(save, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
assertHasCode(preExecute, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
Assert.assertTrue(save.getIssues().stream().anyMatch(issue ->
"join".equals(issue.getNodeId())
&& issue.getMessage().contains("永久等待")
&& issue.getMessage().contains("任一上游完成")));
}
@Test
public void testSaveShouldBlockAllJoinWithDirectConditionalEdge() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, data("分支 A")),
node("b", "codeNode", null, data("分支 B")),
node("join", "codeNode", null, joinData)),
array(
edge("start-a", "start", "a"),
edge("start-b", "start", "b"),
conditionalEdge("a-join", "a", "join", "matched === true"),
edge("b-join", "b", "join")));
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
}
@Test
public void testSaveShouldBlockAllJoinFromCustomConditionSource() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject conditionalSource = data("高级条件来源");
conditionalSource.put("condition", "score > 0");
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, conditionalSource),
node("b", "codeNode", null, data("普通来源")),
node("join", "codeNode", null, joinData)),
array(
edge("start-a", "start", "a"),
edge("start-b", "start", "b"),
edge("a-join", "a", "join"),
edge("b-join", "b", "join")));
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
}
@Test
public void testSaveShouldBlockInvalidAndLoopChildJoinModes() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject invalidData = data("非法汇聚");
invalidData.put("joinMode", "first");
JSONObject loopData = loopData(
fixedParameter("count", "2", "Number"), null);
JSONObject childData = data("循环子节点");
childData.put("joinMode", "all");
String content = workflowJson(
array(
node("invalid", "codeNode", null, invalidData),
node("loop", "loopNode", null, loopData),
node("child", "codeNode", "loop", childData)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "JOIN_MODE_INVALID");
assertHasCode(result, "JOIN_MODE_LOOP_CHILD_UNSUPPORTED");
Assert.assertTrue(result.getIssues().stream().anyMatch(issue ->
"invalid".equals(issue.getNodeId())
&& "JOIN_MODE_INVALID".equals(issue.getCode())));
Assert.assertTrue(result.getIssues().stream().anyMatch(issue ->
"child".equals(issue.getNodeId())
&& "JOIN_MODE_LOOP_CHILD_UNSUPPORTED".equals(issue.getCode())));
}
@Test
public void testSaveShouldAllowSingleConditionalInboundAllJoin() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("单入边汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("join", "codeNode", null, joinData)),
array(conditionalEdge(
"start-join", "start", "join", "enabled === true")));
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
/**
* 验证保存阶段接受合法的正则条件规则。
*/
@@ -992,4 +1149,13 @@ public class WorkflowCheckServiceTest {
edge.put("target", target);
return edge;
}
private static JSONObject conditionalEdge(
String id, String source, String target, String condition) {
JSONObject edge = edge(id, source, target);
JSONObject data = new JSONObject();
data.put("condition", condition);
edge.put("data", data);
return edge;
}
}

View File

@@ -219,6 +219,38 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
}
/**
* 空文件参数应统一归一化为空数组,避免旧客户端空字符串触发格式错误。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldTreatBlankFileValuesAsEmptyList()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Object[] emptyValues = {null, "", " ", List.of()};
for (Object emptyValue : emptyValues) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("attachments", emptyValue);
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables);
Assert.assertEquals(List.of(), normalized.get("attachments"));
}
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("attachments", List.of(
" ",
"https://files.example.com/contracts/contract.docx"));
List<?> normalizedFiles = (List<?>) resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables).get("attachments");
Assert.assertEquals(1, normalizedFiles.size());
}
/**
* 文件参数应接受远程 URL 字符串数组并自动提取文件名。
*
@@ -446,6 +478,30 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertEquals("https://example.com/image.png", image.get("url"));
}
/**
* 空图片参数应从运行变量中移除,避免向执行引擎的并发 Map 写入 null。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldRemoveBlankImageValues()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Object[] emptyValues = {null, "", " "};
for (Object emptyValue : emptyValues) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("image_input", emptyValue);
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithImageStartParameter(),
variables);
Assert.assertFalse(normalized.containsKey("image_input"));
Assert.assertFalse(normalized.containsValue(null));
}
}
/**
* 运行入口不应接收 Data URI避免 Base64 写入工作流状态和审计参数。
*

View File

@@ -3,7 +3,7 @@ package tech.easyflow.ai.service;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
@@ -11,6 +11,7 @@ import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -21,14 +22,14 @@ import static org.mockito.Mockito.when;
public class WorkflowUsageAuthorizationServiceTest {
/**
* 验证禁用工作流即使资源权限允许也不能被使用。
* 验证未发布工作流即使资源权限允许也不能被使用。
*/
@Test
public void shouldRejectDisabledWorkflow() {
public void shouldRejectUnpublishedWorkflow() {
BigInteger workflowId = BigInteger.valueOf(101);
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
Workflow workflow = workflow(workflowId, BigInteger.TEN, EnumDataStatus.UNAVAILABLE.getCode());
Workflow workflow = workflow(workflowId, BigInteger.TEN, PublishStatus.DRAFT, Map.of());
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
when(resourceAccessService.canAccess(
@@ -58,7 +59,8 @@ public class WorkflowUsageAuthorizationServiceTest {
Workflow workflow = workflow(
workflowId,
BigInteger.valueOf(20),
EnumDataStatus.AVAILABLE.getCode());
PublishStatus.PUBLISHED,
Map.of("title", "published"));
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
WorkflowUsageAuthorizationService service =
@@ -71,17 +73,50 @@ public class WorkflowUsageAuthorizationServiceTest {
}
/**
* 验证启用、同租户且具有使用权限的工作流可以返回。
* 验证已发布、同租户且具有使用权限的工作流返回发布视图
*/
@Test
public void shouldReturnUsableWorkflow() {
public void shouldReturnPublishedWorkflowView() {
BigInteger workflowId = BigInteger.valueOf(103);
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
Workflow workflow = workflow(
workflowId,
BigInteger.TEN,
EnumDataStatus.AVAILABLE.getCode());
PublishStatus.PUBLISHED,
Map.of("title", "published"));
Workflow published = new Workflow();
published.setId(workflowId);
published.setTitle("发布版");
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
when(resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE)).thenReturn(true);
when(workflowService.toPublishedView(workflow)).thenReturn(published);
WorkflowUsageAuthorizationService service =
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService);
Workflow result = service.requireUsableWorkflow(workflowId, account, "工作流不可用");
Assert.assertSame(result, published);
}
/**
* 验证发布状态异常但缺少快照的工作流不可被后台任务使用。
*/
@Test
public void shouldRejectPublishedWorkflowWithoutSnapshot() {
BigInteger workflowId = BigInteger.valueOf(104);
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
Workflow workflow = workflow(
workflowId,
BigInteger.TEN,
PublishStatus.PUBLISHED,
Map.of());
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
when(resourceAccessService.canAccess(
@@ -92,9 +127,10 @@ public class WorkflowUsageAuthorizationServiceTest {
WorkflowUsageAuthorizationService service =
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService);
Workflow result = service.requireUsableWorkflow(workflowId, account, "工作流不可用");
Assert.assertSame(result, workflow);
Assert.assertThrows(
BusinessException.class,
() -> service.requireUsableWorkflow(workflowId, account, "工作流不可用")
);
}
/**
@@ -102,14 +138,18 @@ public class WorkflowUsageAuthorizationServiceTest {
*
* @param id 工作流 ID
* @param tenantId 租户 ID
* @param status 工作流状态
* @param publishStatus 工作流发布状态
* @param snapshot 工作流发布快照
* @return 工作流
*/
private Workflow workflow(BigInteger id, BigInteger tenantId, Integer status) {
private Workflow workflow(BigInteger id, BigInteger tenantId,
PublishStatus publishStatus,
Map<String, Object> snapshot) {
Workflow workflow = new Workflow();
workflow.setId(id);
workflow.setTenantId(tenantId);
workflow.setStatus(status);
workflow.setPublishStatus(publishStatus.getCode());
workflow.setPublishedSnapshotJson(snapshot);
return workflow;
}

View File

@@ -24,6 +24,26 @@ public class WorkflowSharePolicyTest {
Assert.assertNotEquals("share-key", first);
}
/**
* 验证匿名访客归属摘要稳定、定长且按分享密钥隔离。
*/
@Test
public void shouldHashChatVisitorPerShareWithoutLeakingIdentity() {
String visitorId = "00112233445566778899aabbccddeeff";
String first = WorkflowSharePolicy.hashChatVisitor(
"share-key-a", visitorId);
String second = WorkflowSharePolicy.hashChatVisitor(
"share-key-a", visitorId);
String otherShare = WorkflowSharePolicy.hashChatVisitor(
"share-key-b", visitorId);
Assert.assertEquals(first, second);
Assert.assertEquals(32, first.length());
Assert.assertNotEquals(first, otherShare);
Assert.assertFalse(first.contains(visitorId));
}
/**
* 验证默认过期时间为创建时间后 30 分钟。
*/

View File

@@ -11,13 +11,18 @@
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler-core</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>1.15.7</version>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-base</artifactId>
@@ -48,6 +53,11 @@
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>

View File

@@ -2,11 +2,13 @@ package tech.easyflow.job.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
@AutoConfiguration
@MapperScan("tech.easyflow.job.mapper")
@ComponentScan("tech.easyflow.job")
@EnableConfigurationProperties(SysJobExecutionProperties.class)
public class JobModuleConfig {
public JobModuleConfig() {

View File

@@ -0,0 +1,49 @@
package tech.easyflow.job.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** 防止调度、Worker 与管理 Saga 形成连接池级等待环。 */
@Component
public class SysJobConnectionCapacityValidator implements InitializingBean {
private final SysJobExecutionProperties properties;
private final int poolSize;
private final int quartzThreadCount;
private final boolean schedulerEnabled;
public SysJobConnectionCapacityValidator(
SysJobExecutionProperties properties,
@Value("${spring.datasource.hikari.maximum-pool-size:10}") int poolSize,
@Value("${easy-agents.scheduler.quartz.thread-count:10}") int quartzThreadCount,
@Value("${easy-agents.scheduler.enabled:false}") boolean schedulerEnabled) {
this.properties = properties;
this.poolSize = poolSize;
this.quartzThreadCount = quartzThreadCount;
this.schedulerEnabled = schedulerEnabled;
}
@Override
public void afterPropertiesSet() {
if (!schedulerEnabled || !properties.isEnabled()) return;
int required = requiredPoolSize(
properties.getManagementCommandConcurrency(),
quartzThreadCount,
properties.getWorkerCount(),
properties.getBusinessConnectionReserve());
if (poolSize < required) {
throw new IllegalStateException(
"定时任务连接池容量不足: spring.datasource.hikari.maximum-pool-size="
+ poolSize + ", 至少需要 " + required
+ " (2*management-command-concurrency + quartz.thread-count"
+ " + worker-count + business-connection-reserve)");
}
}
static int requiredPoolSize(int managementConcurrency, int quartzThreads,
int workers, int businessReserve) {
return Math.addExact(Math.multiplyExact(managementConcurrency, 2),
Math.addExact(quartzThreads, Math.addExact(workers, businessReserve)));
}
}

View File

@@ -0,0 +1,80 @@
package tech.easyflow.job.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 定时任务持久执行器配置。 */
@ConfigurationProperties(prefix = "easyflow.job.execution")
public class SysJobExecutionProperties {
private boolean enabled = true;
private int workerCount = 4;
private int managementCommandConcurrency = 4;
private int businessConnectionReserve = 4;
private Duration pollInterval = Duration.ofMillis(500);
private Duration leaseDuration = Duration.ofMinutes(2);
private Duration heartbeatInterval = Duration.ofSeconds(30);
private Duration retryBackoff = Duration.ofSeconds(5);
private Duration shutdownWaitTimeout = Duration.ofSeconds(30);
private int infrastructureRetryLimit = 16;
private int registrationMaxAttempts = 3;
private Duration registrationRetryDelay = Duration.ofMillis(100);
private int registrationQuartzRefireLimit;
private Duration registrationQuartzRefireDelay = Duration.ofMillis(250);
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getWorkerCount() { return workerCount; }
public void setWorkerCount(int value) { this.workerCount = positive(value, "workerCount"); }
public int getManagementCommandConcurrency() { return managementCommandConcurrency; }
public void setManagementCommandConcurrency(int value) {
this.managementCommandConcurrency = positive(value, "managementCommandConcurrency");
}
public int getBusinessConnectionReserve() { return businessConnectionReserve; }
public void setBusinessConnectionReserve(int value) {
this.businessConnectionReserve = positive(value, "businessConnectionReserve");
}
public Duration getPollInterval() { return pollInterval; }
public void setPollInterval(Duration value) { this.pollInterval = positive(value, "pollInterval"); }
public Duration getLeaseDuration() { return leaseDuration; }
public void setLeaseDuration(Duration value) { this.leaseDuration = positive(value, "leaseDuration"); }
public Duration getHeartbeatInterval() { return heartbeatInterval; }
public void setHeartbeatInterval(Duration value) { this.heartbeatInterval = positive(value, "heartbeatInterval"); }
public Duration getRetryBackoff() { return retryBackoff; }
public void setRetryBackoff(Duration value) { this.retryBackoff = positive(value, "retryBackoff"); }
public Duration getShutdownWaitTimeout() { return shutdownWaitTimeout; }
public void setShutdownWaitTimeout(Duration value) { this.shutdownWaitTimeout = positive(value, "shutdownWaitTimeout"); }
public int getInfrastructureRetryLimit() { return infrastructureRetryLimit; }
public void setInfrastructureRetryLimit(int value) { this.infrastructureRetryLimit = positive(value, "infrastructureRetryLimit"); }
public int getRegistrationMaxAttempts() { return registrationMaxAttempts; }
public void setRegistrationMaxAttempts(int value) { this.registrationMaxAttempts = positive(value, "registrationMaxAttempts"); }
public Duration getRegistrationRetryDelay() { return registrationRetryDelay; }
public void setRegistrationRetryDelay(Duration value) { this.registrationRetryDelay = positive(value, "registrationRetryDelay"); }
public int getRegistrationQuartzRefireLimit() { return registrationQuartzRefireLimit; }
public void setRegistrationQuartzRefireLimit(int value) {
if (value < 0) throw new IllegalArgumentException("registrationQuartzRefireLimit must not be negative");
this.registrationQuartzRefireLimit = value;
}
public Duration getRegistrationQuartzRefireDelay() { return registrationQuartzRefireDelay; }
public void setRegistrationQuartzRefireDelay(Duration value) { this.registrationQuartzRefireDelay = positive(value, "registrationQuartzRefireDelay"); }
public void validate() {
Duration minimumLease = heartbeatInterval.multipliedBy(3);
if (leaseDuration.compareTo(minimumLease) <= 0) {
throw new IllegalStateException(
"leaseDuration must be greater than three times heartbeatInterval");
}
}
private static int positive(int value, String name) {
if (value < 1) throw new IllegalArgumentException(name + " must be positive");
return value;
}
private static Duration positive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " must be positive");
}
return value;
}
}

View File

@@ -81,6 +81,12 @@ public class SysJobBase extends DateEntity implements Serializable {
@Column(comment = "数据状态")
private Integer status;
/**
* 调度定义代际。每次修改定义或从停止进入运行时递增,用于隔离旧 Quartz fire。
*/
@Column(comment = "调度定义代际")
private Long scheduleGeneration;
/**
* 创建时间
*/
@@ -199,6 +205,14 @@ public class SysJobBase extends DateEntity implements Serializable {
this.status = status;
}
public Long getScheduleGeneration() {
return scheduleGeneration;
}
public void setScheduleGeneration(Long scheduleGeneration) {
this.scheduleGeneration = scheduleGeneration;
}
public Date getCreated() {
return created;
}

View File

@@ -20,24 +20,86 @@ public class SysJobLogBase implements Serializable {
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键")
private BigInteger id;
/** 执行幂等键的 SHA-256。 */
@Column(comment = "执行幂等键SHA-256")
private String executionKey;
/**
* 任务ID
*/
@Column(comment = "任务ID")
private BigInteger jobId;
/** 触发所属任务代际。 */
@Column(comment = "触发所属任务代际")
private Long jobGeneration;
@Column(tenantId = true, comment = "租户ID")
private BigInteger tenantId;
@Column(comment = "部门ID")
private BigInteger deptId;
/**
* 任务名称
*/
@Column(comment = "任务名称")
private String jobName;
@Column(comment = "任务类型快照")
private Integer jobType;
/**
* 任务参数
*/
@Column(typeHandler = FastjsonTypeHandler.class, comment = "任务参数")
private Map<String, Object> jobParams;
@Column(typeHandler = FastjsonTypeHandler.class, comment = "任务扩展配置快照")
private Map<String, Object> jobOptions;
@Column(comment = "是否允许并发执行")
private Integer allowConcurrent;
@Column(comment = "触发来源")
private String triggerSource;
@Column(comment = "立即触发调用标识")
private String invocationId;
@Column(comment = "计划触发时间")
private Date scheduledFireTime;
@Column(comment = "实际触发时间")
private Date actualFireTime;
@Column(comment = "Quartz物理触发实例")
private String fireInstanceId;
@Column(comment = "是否为Quartz故障恢复")
private Integer recovering;
@Column(comment = "执行节点")
private String leaseOwner;
@Column(comment = "本轮执行令牌")
private String executionToken;
@Column(comment = "租约到期时间")
private Date leaseUntil;
@Column(comment = "最近续租时间")
private Date heartbeatTime;
@Column(comment = "基础设施恢复次数")
private Integer attemptCount;
@Column(comment = "下次恢复时间")
private Date nextRetryTime;
@Column(comment = "状态版本")
private Long version;
/**
* 执行结果
*/
@@ -88,6 +150,9 @@ public class SysJobLogBase implements Serializable {
this.id = id;
}
public String getExecutionKey() { return executionKey; }
public void setExecutionKey(String executionKey) { this.executionKey = executionKey; }
public BigInteger getJobId() {
return jobId;
}
@@ -96,6 +161,14 @@ public class SysJobLogBase implements Serializable {
this.jobId = jobId;
}
public Long getJobGeneration() { return jobGeneration; }
public void setJobGeneration(Long jobGeneration) { this.jobGeneration = jobGeneration; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getDeptId() { return deptId; }
public void setDeptId(BigInteger deptId) { this.deptId = deptId; }
public String getJobName() {
return jobName;
}
@@ -104,6 +177,9 @@ public class SysJobLogBase implements Serializable {
this.jobName = jobName;
}
public Integer getJobType() { return jobType; }
public void setJobType(Integer jobType) { this.jobType = jobType; }
public Map<String, Object> getJobParams() {
return jobParams;
}
@@ -112,6 +188,37 @@ public class SysJobLogBase implements Serializable {
this.jobParams = jobParams;
}
public Map<String, Object> getJobOptions() { return jobOptions; }
public void setJobOptions(Map<String, Object> jobOptions) { this.jobOptions = jobOptions; }
public Integer getAllowConcurrent() { return allowConcurrent; }
public void setAllowConcurrent(Integer allowConcurrent) { this.allowConcurrent = allowConcurrent; }
public String getTriggerSource() { return triggerSource; }
public void setTriggerSource(String triggerSource) { this.triggerSource = triggerSource; }
public String getInvocationId() { return invocationId; }
public void setInvocationId(String invocationId) { this.invocationId = invocationId; }
public Date getScheduledFireTime() { return scheduledFireTime; }
public void setScheduledFireTime(Date scheduledFireTime) { this.scheduledFireTime = scheduledFireTime; }
public Date getActualFireTime() { return actualFireTime; }
public void setActualFireTime(Date actualFireTime) { this.actualFireTime = actualFireTime; }
public String getFireInstanceId() { return fireInstanceId; }
public void setFireInstanceId(String fireInstanceId) { this.fireInstanceId = fireInstanceId; }
public Integer getRecovering() { return recovering; }
public void setRecovering(Integer recovering) { this.recovering = recovering; }
public String getLeaseOwner() { return leaseOwner; }
public void setLeaseOwner(String leaseOwner) { this.leaseOwner = leaseOwner; }
public String getExecutionToken() { return executionToken; }
public void setExecutionToken(String executionToken) { this.executionToken = executionToken; }
public Date getLeaseUntil() { return leaseUntil; }
public void setLeaseUntil(Date leaseUntil) { this.leaseUntil = leaseUntil; }
public Date getHeartbeatTime() { return heartbeatTime; }
public void setHeartbeatTime(Date heartbeatTime) { this.heartbeatTime = heartbeatTime; }
public Integer getAttemptCount() { return attemptCount; }
public void setAttemptCount(Integer attemptCount) { this.attemptCount = attemptCount; }
public Date getNextRetryTime() { return nextRetryTime; }
public void setNextRetryTime(Date nextRetryTime) { this.nextRetryTime = nextRetryTime; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
public String getJobResult() {
return jobResult;
}

View File

@@ -0,0 +1,7 @@
package tech.easyflow.job.execution;
import tech.easyflow.job.entity.SysJobLog;
/** 当前节点持有租约的一次任务执行。 */
public record ClaimedSysJobExecution(SysJobLog execution, String owner, String token) {
}

View File

@@ -0,0 +1,87 @@
package tech.easyflow.job.execution;
import com.easyagents.scheduler.ScheduleFireContext;
import com.easyagents.scheduler.ScheduleHandler;
import com.easyagents.scheduler.ScheduleRefireException;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.RecoverableDataAccessException;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.TransactionException;
import tech.easyflow.job.config.SysJobExecutionProperties;
import java.util.concurrent.locks.LockSupport;
/** Quartz 短 Handler只负责把触发写入数据库执行账本。 */
@Component
public class EasyFlowScheduleHandler implements ScheduleHandler {
public static final String CODE = "easyflow.job.execution";
private final SysJobExecutionRegistrar registrar;
private final SysJobExecutionProperties properties;
private final SysJobExecutionMetrics metrics;
public EasyFlowScheduleHandler(SysJobExecutionRegistrar registrar,
SysJobExecutionProperties properties,
SysJobExecutionMetrics metrics) {
this.registrar = registrar;
this.properties = properties;
this.metrics = metrics;
}
@Override
public String code() {
return CODE;
}
@Override
public void execute(ScheduleFireContext context) {
long startedAt = System.nanoTime();
boolean success = false;
try {
executeWithRetry(context);
success = true;
} finally {
metrics.recordRegistration(System.nanoTime() - startedAt, success);
}
}
private void executeWithRetry(ScheduleFireContext context) {
for (int attempt = 1; ; attempt++) {
try {
registrar.register(context);
return;
} catch (RuntimeException exception) {
if (!isRetryable(exception)) {
throw exception;
}
if (attempt >= properties.getRegistrationMaxAttempts()) {
throw refire(exception);
}
LockSupport.parkNanos(properties.getRegistrationRetryDelay().toNanos());
if (Thread.currentThread().isInterrupted()) {
Thread.currentThread().interrupt();
throw refire(exception);
}
}
}
}
private ScheduleRefireException refire(RuntimeException exception) {
return new ScheduleRefireException(
"定时任务触发登记失败,请求 Quartz 保留本次触发并重新执行",
exception,
properties.getRegistrationQuartzRefireLimit(),
properties.getRegistrationQuartzRefireDelay());
}
private static boolean isRetryable(RuntimeException exception) {
if (exception instanceof TransactionException) return true;
if (!(exception instanceof DataAccessException dataAccessException)) return false;
return dataAccessException instanceof TransientDataAccessException
|| dataAccessException instanceof RecoverableDataAccessException
|| dataAccessException instanceof DataAccessResourceFailureException;
}
}

View File

@@ -0,0 +1,8 @@
package tech.easyflow.job.execution;
/** 任务在领取后、业务执行前已不再满足运行条件。 */
public class SysJobCancelledException extends RuntimeException {
public SysJobCancelledException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,16 @@
package tech.easyflow.job.execution;
import java.math.BigInteger;
import java.time.Instant;
/** 可供三类业务执行器读取的稳定幂等上下文。 */
public record SysJobExecutionContext(
BigInteger executionId,
String executionKey,
BigInteger tenantId,
int attempt,
String triggerSource,
String invocationId,
Instant scheduledFireTime
) {
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.job.execution;
import java.util.Optional;
/** 当前 Worker 线程的任务幂等上下文。 */
public final class SysJobExecutionContextHolder {
private static final ThreadLocal<SysJobExecutionContext> CURRENT = new ThreadLocal<>();
private SysJobExecutionContextHolder() {
}
/** 返回当前执行上下文;异步派生线程需要由业务显式传递。 */
public static Optional<SysJobExecutionContext> current() {
return Optional.ofNullable(CURRENT.get());
}
static void set(SysJobExecutionContext context) {
if (CURRENT.get() != null) {
throw new IllegalStateException("定时任务执行上下文不允许嵌套");
}
CURRENT.set(context);
}
static void clear() {
CURRENT.remove();
}
}

View File

@@ -0,0 +1,105 @@
package tech.easyflow.job.execution;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/** 定时任务登记、队列、租约和执行指标。 */
@Component
public class SysJobExecutionMetrics {
private final Timer registrationTimer;
private final Timer executionTimer;
private final Counter registrationFailures;
private final Counter claimConflicts;
private final Counter leaseTakeovers;
private final Counter lostLeases;
private final Map<String, Counter> terminalCounters;
private final AtomicLong pending = new AtomicLong();
private final AtomicLong running = new AtomicLong();
private final AtomicLong localActive = new AtomicLong();
private final AtomicLong oldestBacklogMillis = new AtomicLong();
public SysJobExecutionMetrics(MeterRegistry registry) {
registrationTimer = Timer.builder("easyflow.job.registration.duration")
.description("Quartz trigger ledger registration duration")
.register(registry);
executionTimer = Timer.builder("easyflow.job.execution.duration")
.description("Business job execution duration")
.register(registry);
registrationFailures = counter(registry, "easyflow.job.registration.failures");
claimConflicts = counter(registry, "easyflow.job.claim.conflicts");
leaseTakeovers = counter(registry, "easyflow.job.lease.takeovers");
lostLeases = counter(registry, "easyflow.job.lease.lost");
terminalCounters = Map.of(
"success", terminalCounter(registry, "success"),
"failure", terminalCounter(registry, "failure"),
"dead", terminalCounter(registry, "dead"),
"cancelled", terminalCounter(registry, "cancelled"));
gauge(registry, "easyflow.job.queue.pending", pending);
gauge(registry, "easyflow.job.queue.running", running);
gauge(registry, "easyflow.job.execution.local_active", localActive);
Gauge.builder("easyflow.job.queue.oldest_backlog_seconds", oldestBacklogMillis,
value -> value.get() / 1_000.0D)
.register(registry);
}
public void recordRegistration(long durationNanos, boolean success) {
registrationTimer.record(durationNanos, TimeUnit.NANOSECONDS);
if (!success) registrationFailures.increment();
}
public void recordClaimConflict() {
claimConflicts.increment();
}
public void recordLeaseTakeover() {
leaseTakeovers.increment();
}
public void recordLostLease() {
lostLeases.increment();
}
public void recordDead() {
terminalCounters.get("dead").increment();
}
public void executionStarted() {
localActive.incrementAndGet();
}
public void executionFinished(String status, long durationNanos) {
localActive.updateAndGet(value -> Math.max(0L, value - 1L));
executionTimer.record(durationNanos, TimeUnit.NANOSECONDS);
// 失租、fencing 拒绝或关闭中断时没有本节点可确认的终态status 合法为 null。
Counter counter = status == null ? null : terminalCounters.get(status);
if (counter != null) counter.increment();
}
public void updateQueue(long pendingCount, long runningCount, long oldestMillis) {
pending.set(pendingCount);
running.set(runningCount);
oldestBacklogMillis.set(Math.max(0L, oldestMillis));
}
private static Counter counter(MeterRegistry registry, String name) {
return Counter.builder(name).register(registry);
}
private static Counter terminalCounter(MeterRegistry registry, String status) {
return Counter.builder("easyflow.job.execution.terminal")
.tag("status", status)
.register(registry);
}
private static void gauge(MeterRegistry registry, String name, AtomicLong value) {
Gauge.builder(name, value, AtomicLong::get).register(registry);
}
}

View File

@@ -0,0 +1,8 @@
package tech.easyflow.job.execution;
import com.easyagents.scheduler.ScheduleFireContext;
/** 将调度触发幂等登记到持久执行账本。 */
public interface SysJobExecutionRegistrar {
void register(ScheduleFireContext context);
}

View File

@@ -0,0 +1,332 @@
package tech.easyflow.job.execution;
import com.easyagents.scheduler.ScheduleFireContext;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.common.constant.enums.EnumJobResult;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.mapper.SysJobLogMapper;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.job.JobConstant;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Date;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.locks.LockSupport;
/** `tb_sys_job_log` 执行账本及跨节点租约协议。 */
@Repository
public class SysJobExecutionStore implements SysJobExecutionRegistrar {
/** 单次认领最多清退的终态候选数,兼顾队首疏通和短事务边界。 */
private static final int MAX_TERMINAL_ROWS_PER_CLAIM = 64;
/** 登记与领取锁序竞争时的短事务重试上限。 */
private static final int MAX_CLAIM_CONCURRENCY_ATTEMPTS = 3;
private final SysJobMapper jobMapper;
private final SysJobLogMapper logMapper;
private final SysJobExecutionProperties properties;
private final SysJobExecutionMetrics metrics;
private final TransactionTemplate requiresNew;
public SysJobExecutionStore(SysJobMapper jobMapper,
SysJobLogMapper logMapper,
SysJobExecutionProperties properties,
SysJobExecutionMetrics metrics,
PlatformTransactionManager transactionManager) {
this.jobMapper = jobMapper;
this.logMapper = logMapper;
this.properties = properties;
this.metrics = metrics;
this.requiresNew = new TransactionTemplate(transactionManager);
this.requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.requiresNew.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
}
@Override
public void register(ScheduleFireContext context) {
BigInteger jobId = parseJobId(context);
long fireGeneration = parseGeneration(context);
String executionKey = executionKey(jobId, fireGeneration, context);
requiresNew.execute(status -> TenantManager.withoutTenantCondition(() -> {
// 管理事务会先锁定任务行再改变启停状态。这里使用同一行锁,确保 Quartz
// 即刻触发时等待管理事务提交,避免读取旧 STOP 后把本次 fire 当成功吞掉。
SysJob job = jobMapper.selectByIdForUpdate(jobId);
if (job == null
|| !Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(job.getStatus())
|| !Objects.equals(job.getScheduleGeneration(), fireGeneration)) {
return null;
}
SysJobLog execution = snapshot(job, fireGeneration, context, executionKey);
try {
logMapper.insert(execution);
} catch (DuplicateKeyException duplicate) {
// 只有 execution_key 命中才是可吞掉的幂等重复;主键或其他
// 唯一约束冲突必须上抛,让 Quartz 保留 fire 并进行持久化重试。
if (logMapper.selectIdByExecutionKey(executionKey) == null) {
throw duplicate;
}
}
return null;
}));
}
public Optional<ClaimedSysJobExecution> claimOne(String owner) {
return TenantManager.withoutTenantCondition(() -> {
for (int index = 0; index < MAX_TERMINAL_ROWS_PER_CLAIM; index++) {
ClaimAttempt attempt = executeClaimTransaction(owner);
if (attempt == null) {
return Optional.empty();
}
if (attempt.claim() != null) {
return Optional.of(attempt.claim());
}
if (!attempt.retryImmediately()) {
return Optional.empty();
}
}
return Optional.empty();
});
}
private ClaimAttempt executeClaimTransaction(String owner) {
for (int attempt = 1; attempt <= MAX_CLAIM_CONCURRENCY_ATTEMPTS; attempt++) {
try {
ClaimAttempt expired = claimExpiredIfPresent(owner);
if (expired != null) return expired;
return requiresNew.execute(status -> claimInTransaction(owner, false));
} catch (ConcurrencyFailureException exception) {
if (attempt == MAX_CLAIM_CONCURRENCY_ATTEMPTS) throw exception;
// 短暂抖动后用全新事务重试,不改变 execution也不把可恢复的锁冲突
// 上抛为一次业务失败。
LockSupport.parkNanos(ThreadLocalRandom.current().nextLong(
1_000_000L, 5_000_001L));
}
}
throw new IllegalStateException("定时任务认领重试状态异常");
}
private ClaimAttempt claimExpiredIfPresent(String owner) {
// 空范围 FOR UPDATE 会保留状态索引的末端间隙锁;同一事务再把 PENDING
// 更新为 RUNNING 时会与其他 Worker 互锁,因此只在确认有候选后加锁。
if (logMapper.selectExpiredClaimCandidateIdWithoutLock() == null) return null;
return requiresNew.execute(status -> claimInTransaction(owner, true));
}
private ClaimAttempt claimInTransaction(String owner, boolean expired) {
SysJobLog candidate = expired
? logMapper.selectExpiredClaimCandidate()
: logMapper.selectPendingClaimCandidate();
// 无锁探测到的过期行可能已被其他 Worker 锁住。SKIP LOCKED 此时返回空,
// 不能结束整轮领取,否则一条受阻的过期记录会让 PENDING 队列反复空转。
if (candidate == null) return expired ? null : ClaimAttempt.stop();
if (candidate.getAttemptCount() != null
&& candidate.getAttemptCount() >= properties.getInfrastructureRetryLimit()) {
int marked = expired
? logMapper.markExpiredDead(
candidate.getId(), "执行节点多次失联,已停止自动接管")
: logMapper.markPendingDead(
candidate.getId(), "基础设施多次故障,已停止自动重试");
if (marked == 1) {
metrics.recordDead();
}
return marked == 1 ? ClaimAttempt.retry() : ClaimAttempt.stop();
}
SysJob job = jobMapper.selectByIdForUpdate(candidate.getJobId());
if (job == null
|| !Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(job.getStatus())) {
return claimAndCancel(candidate, owner, expired, "任务已停止或删除")
? ClaimAttempt.retry() : ClaimAttempt.stop();
}
if (!Integer.valueOf(1).equals(candidate.getAllowConcurrent())
&& logMapper.selectOtherActiveForUpdate(
candidate.getJobId(), candidate.getId()) != null) {
if (expired) {
logMapper.releaseExpiredForRetry(candidate.getId(),
micros(properties.getRetryBackoff()));
} else {
logMapper.deferPending(candidate.getId(), micros(properties.getRetryBackoff()));
}
metrics.recordClaimConflict();
return ClaimAttempt.stop();
}
String token = UUID.randomUUID().toString();
int claimed = expired
? logMapper.claimExpired(candidate.getId(), owner, token,
micros(properties.getLeaseDuration()))
: logMapper.claimPending(candidate.getId(), owner, token,
micros(properties.getLeaseDuration()));
if (claimed != 1) {
metrics.recordClaimConflict();
return ClaimAttempt.stop();
}
if (expired) metrics.recordLeaseTakeover();
candidate.setLeaseOwner(owner);
candidate.setExecutionToken(token);
candidate.setStatus(EnumJobResult.RUNNING.getCode());
return ClaimAttempt.claimed(new ClaimedSysJobExecution(candidate, owner, token));
}
private boolean claimAndCancel(SysJobLog candidate, String owner, boolean expired, String reason) {
String token = UUID.randomUUID().toString();
int claimed = expired
? logMapper.claimExpired(candidate.getId(), owner, token,
micros(properties.getLeaseDuration()))
: logMapper.claimPending(candidate.getId(), owner, token,
micros(properties.getLeaseDuration()));
if (claimed == 1) {
logMapper.finishOwned(candidate.getId(), owner, token,
EnumJobResult.CANCELLED.getCode(), null, reason);
}
return claimed == 1;
}
public boolean heartbeat(ClaimedSysJobExecution claim) {
return TenantManager.withoutTenantCondition(() -> logMapper.renewLease(
claim.execution().getId(), claim.owner(), claim.token(),
micros(properties.getLeaseDuration())) == 1);
}
public boolean finish(ClaimedSysJobExecution claim, int status, String result, String error) {
return TenantManager.withoutTenantCondition(() -> logMapper.finishOwned(
claim.execution().getId(), claim.owner(), claim.token(), status,
truncate(result, 15000), truncate(error, 4000)) == 1);
}
public boolean releaseForInfrastructureRetry(ClaimedSysJobExecution claim, String error) {
return TenantManager.withoutTenantCondition(() -> logMapper.releaseOwnedForRetry(
claim.execution().getId(), claim.owner(), claim.token(),
micros(properties.getRetryBackoff()), truncate(error, 4000)) == 1);
}
public int cancelPending(BigInteger jobId, String reason) {
Integer cancelled = requiresNew.execute(status -> TenantManager.withoutTenantCondition(() -> {
// 先锁待清理账本,再锁任务行,保持与 Worker 一致的 log -> job 锁序。
logMapper.selectPendingIdsByJobIdForUpdate(jobId);
// 旧 STOP/删除命令失锁后可能与新 START 重叠。锁定并重新读取当前任务状态,
// 只有最终仍为 STOP或已删除时才清理避免误取消新一代待执行记录。
SysJob job = jobMapper.selectByIdForUpdate(jobId);
if (job != null
&& Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(job.getStatus())) {
return 0;
}
return logMapper.cancelPendingByJobId(jobId, truncate(reason, 4000));
}));
return cancelled == null ? 0 : cancelled;
}
public SysJobQueueSnapshot queueSnapshot() {
return TenantManager.withoutTenantCondition(() -> new SysJobQueueSnapshot(
logMapper.countPending(),
logMapper.countRunning(),
logMapper.selectOldestPendingMillis()));
}
private SysJobLog snapshot(SysJob job, long fireGeneration, ScheduleFireContext context,
String executionKey) {
SysJobLog log = new SysJobLog();
String source = context.invocationId() == null ? "SCHEDULED" : "MANUAL";
log.setExecutionKey(executionKey);
log.setJobId(job.getId());
log.setJobGeneration(fireGeneration);
log.setTenantId(job.getTenantId());
log.setDeptId(job.getDeptId());
log.setJobName(job.getJobName());
log.setJobType(job.getJobType());
log.setJobParams(job.getJobParams());
log.setJobOptions(job.getOptions());
log.setAllowConcurrent(job.getAllowConcurrent());
log.setTriggerSource(source);
log.setInvocationId(context.invocationId());
log.setScheduledFireTime(Date.from(context.scheduledFireTime()));
log.setActualFireTime(Date.from(context.actualFireTime()));
log.setFireInstanceId(context.fireInstanceId());
log.setRecovering(context.recovering() ? 1 : 0);
log.setAttemptCount(0);
log.setNextRetryTime(Date.from(context.actualFireTime()));
log.setStatus(EnumJobResult.PENDING.getCode());
log.setVersion(0L);
log.setCreated(new Date());
return log;
}
private static String executionKey(BigInteger jobId, long fireGeneration,
ScheduleFireContext context) {
String source = context.invocationId() == null ? "SCHEDULED" : "MANUAL";
String canonical = source + '|' + jobId + '|' + fireGeneration + '|'
+ (context.invocationId() == null
? context.scheduledFireTime().toEpochMilli()
: context.invocationId());
return sha256(canonical);
}
private static BigInteger parseJobId(ScheduleFireContext context) {
try {
return new BigInteger(context.scheduleId().name());
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("非法 EasyFlow 定时任务标识: " + context.scheduleId(), exception);
}
}
private static long parseGeneration(ScheduleFireContext context) {
String value = context.parameters().get(JobConstant.SCHEDULE_GENERATION);
try {
long generation = Long.parseLong(value);
if (generation < 0L) throw new NumberFormatException("negative generation");
return generation;
} catch (NumberFormatException | NullPointerException exception) {
throw new IllegalArgumentException(
"非法 EasyFlow 定时任务调度代际: " + value, exception);
}
}
private static String sha256(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JDK 缺少 SHA-256", exception);
}
}
private static String truncate(String value, int maxLength) {
return value == null || value.length() <= maxLength ? value : value.substring(0, maxLength);
}
private static long micros(Duration duration) {
return Math.max(1L, duration.toNanos() / 1_000L);
}
private record ClaimAttempt(ClaimedSysJobExecution claim, boolean retryImmediately) {
private static ClaimAttempt claimed(ClaimedSysJobExecution claim) {
return new ClaimAttempt(claim, false);
}
private static ClaimAttempt retry() {
return new ClaimAttempt(null, true);
}
private static ClaimAttempt stop() {
return new ClaimAttempt(null, false);
}
}
}

View File

@@ -0,0 +1,372 @@
package tech.easyflow.job.execution;
import cn.hutool.core.exceptions.ExceptionUtil;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
import tech.easyflow.common.constant.enums.EnumJobResult;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.entity.SysJobLog;
import java.lang.management.ManagementFactory;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
/** 固定并发、数据库驱动的重任务 Worker。 */
@Component
public class SysJobExecutionWorker implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(SysJobExecutionWorker.class);
private final SysJobExecutionStore store;
private final SysJobInvoker invoker;
private final SysJobExecutionProperties properties;
private final SysJobExecutionMetrics metrics;
private final String owner = buildOwner();
private final AtomicBoolean running = new AtomicBoolean();
private final Map<String, ActiveExecution> active = new ConcurrentHashMap<>();
private ExecutorService workers;
private ScheduledExecutorService heartbeat;
public SysJobExecutionWorker(SysJobExecutionStore store,
SysJobInvoker invoker,
SysJobExecutionProperties properties,
SysJobExecutionMetrics metrics) {
this.store = store;
this.invoker = invoker;
this.properties = properties;
this.metrics = metrics;
}
@Override
public synchronized void start() {
if (!properties.isEnabled()) return;
properties.validate();
if (!running.compareAndSet(false, true)) return;
AtomicInteger workerNumber = new AtomicInteger();
workers = Executors.newFixedThreadPool(properties.getWorkerCount(), runnable -> {
Thread thread = new Thread(runnable,
"easyflow-job-worker-" + workerNumber.incrementAndGet());
// 正常关闭仍等待在途任务;超时且业务代码忽略中断时不阻塞 JVM 退出,
// 未完成记录由数据库租约交给其他节点接管。
thread.setDaemon(true);
return thread;
});
heartbeat = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "easyflow-job-heartbeat");
thread.setDaemon(true);
return thread;
});
for (int i = 0; i < properties.getWorkerCount(); i++) workers.submit(this::workerLoop);
refreshQueueMetrics();
heartbeat.scheduleWithFixedDelay(this::heartbeatAll,
properties.getHeartbeatInterval().toMillis(),
properties.getHeartbeatInterval().toMillis(), TimeUnit.MILLISECONDS);
log.info("定时任务 Worker 已启动: owner={}, workers={}", owner, properties.getWorkerCount());
}
private void workerLoop() {
int consecutiveFailures = 0;
while (running.get()) {
try {
Optional<ClaimedSysJobExecution> claim = store.claimOne(owner);
consecutiveFailures = 0;
if (claim.isPresent()) {
execute(claim.get());
if (running.get()) Thread.interrupted();
}
else idle();
} catch (RuntimeException exception) {
log.error("定时任务 Worker 领取执行记录失败", exception);
failureBackoff(++consecutiveFailures);
}
}
}
private void execute(ClaimedSysJobExecution claim) {
ActiveExecution activeExecution = new ActiveExecution(claim, Thread.currentThread());
active.put(claim.token(), activeExecution);
long startedAt = System.nanoTime();
String terminalMetric = null;
boolean metricStarted = false;
try {
SysJobExecutionContextHolder.set(toExecutionContext(claim.execution()));
metricStarted = recordExecutionStarted(claim.execution().getId());
terminalMetric = invokeAndFinish(claim, activeExecution);
} finally {
// 上下文和活动租约是 Worker 的正确性状态,必须先于可观测性收尾清理。
// 否则指标实现一旦抛错,线程复用后会永久残留上下文,且旧租约会被持续续期。
SysJobExecutionContextHolder.clear();
synchronized (activeExecution) {
active.remove(claim.token(), activeExecution);
}
if (metricStarted) {
recordExecutionFinished(claim.execution().getId(), terminalMetric,
System.nanoTime() - startedAt);
}
}
}
private String invokeAndFinish(ClaimedSysJobExecution claim,
ActiveExecution activeExecution) {
String terminalMetric = null;
try {
Object result = invoker.execute(toSnapshot(claim.execution()));
String serialized = serializeResult(result, claim.execution().getId());
if (finishOwned(claim, EnumJobResult.SUCCESS.getCode(), serialized, null)) {
terminalMetric = "success";
}
} catch (SysJobCancelledException exception) {
if (finishOwned(claim, EnumJobResult.CANCELLED.getCode(), null,
exception.getMessage())) {
terminalMetric = "cancelled";
}
} catch (SysJobInfrastructureException exception) {
String message = ExceptionUtil.getRootCauseMessage(exception);
try {
if (!store.releaseForInfrastructureRetry(claim, message)) {
activeExecution.abandon().set(true);
metrics.recordLostLease();
log.warn("基础设施故障记录释放被 fencing 拒绝: executionId={}",
claim.execution().getId());
}
} catch (RuntimeException releaseFailure) {
activeExecution.abandon().set(true);
metrics.recordLostLease();
log.error("基础设施故障记录释放失败,保留租约等待接管: executionId={}",
claim.execution().getId(), releaseFailure);
}
log.warn("定时任务执行前基础设施故障,已安排重试: executionId={}, jobId={}",
claim.execution().getId(), claim.execution().getJobId(), exception);
} catch (Exception exception) {
terminalMetric = handleBusinessFailure(claim, activeExecution, exception);
} catch (Error error) {
if (error instanceof VirtualMachineError || error instanceof ThreadDeath) {
throw error;
}
// FutureTask 会吞掉 Error 并结束整个永久轮询任务;非 VM Error 必须
// 记账后继续,让固定 workerCount 不因单个业务实现错误永久缩水。
terminalMetric = handleBusinessFailure(claim, activeExecution, error);
}
return terminalMetric;
}
private boolean recordExecutionStarted(java.math.BigInteger executionId) {
try {
metrics.executionStarted();
return true;
} catch (RuntimeException exception) {
log.warn("记录定时任务开始指标失败,不影响业务执行: executionId={}",
executionId, exception);
return false;
}
}
private void recordExecutionFinished(java.math.BigInteger executionId,
String status, long durationNanos) {
try {
metrics.executionFinished(status, durationNanos);
} catch (RuntimeException exception) {
log.warn("记录定时任务完成指标失败,不影响业务终态: executionId={}",
executionId, exception);
}
}
private String handleBusinessFailure(ClaimedSysJobExecution claim,
ActiveExecution activeExecution,
Throwable failure) {
if (activeExecution.abandon().get()) {
log.warn("定时任务因失租或关闭中断,保留租约等待其他节点接管: "
+ "executionId={}, jobId={}",
claim.execution().getId(), claim.execution().getJobId(), failure);
} else {
log.error("定时任务业务执行失败: executionId={}, jobId={}",
claim.execution().getId(), claim.execution().getJobId(), failure);
String message = ExceptionUtil.getRootCauseMessage(failure);
if (finishOwned(claim, EnumJobResult.FAIL.getCode(), null, message)) {
return "failure";
}
}
return null;
}
private void heartbeatAll() {
for (ActiveExecution execution : active.values()) {
if (execution.abandon().get()) continue;
try {
if (!store.heartbeat(execution.claim())) {
if (!abandonCurrent(execution)) continue;
log.warn("定时任务已失去租约,中断旧 Worker: executionId={}",
execution.claim().execution().getId());
metrics.recordLostLease();
}
} catch (RuntimeException exception) {
if (!abandonCurrent(execution)) continue;
log.error("定时任务续租失败,中断执行以降低重复副作用风险: executionId={}",
execution.claim().execution().getId(), exception);
metrics.recordLostLease();
}
}
refreshQueueMetrics();
}
/**
* 仅当执行仍是当前活动项时中断其线程。
*
* <p>Worker 线程会被执行池复用;这里必须与 {@link #execute} 的 finally
* 使用同一监视器,避免旧心跳在活动项移除后误伤同一线程上的下一任务。</p>
*/
private boolean abandonCurrent(ActiveExecution execution) {
synchronized (execution) {
if (!active.remove(execution.claim().token(), execution)) return false;
execution.abandon().set(true);
execution.thread().interrupt();
return true;
}
}
private void idle() {
LockSupport.parkNanos(properties.getPollInterval().toNanos());
}
private void failureBackoff(int consecutiveFailures) {
long base = properties.getPollInterval().toNanos();
int shift = Math.min(6, Math.max(0, consecutiveFailures - 1));
long capped = Math.min(TimeUnit.SECONDS.toNanos(30), base << shift);
long jitter = ThreadLocalRandom.current().nextLong(Math.max(1L, base));
LockSupport.parkNanos(Math.min(TimeUnit.SECONDS.toNanos(30), capped + jitter));
}
private static SysJob toSnapshot(SysJobLog execution) {
SysJob job = new SysJob();
job.setId(execution.getJobId());
job.setTenantId(execution.getTenantId());
job.setDeptId(execution.getDeptId());
job.setJobName(execution.getJobName());
job.setJobType(execution.getJobType());
job.setJobParams(execution.getJobParams());
job.setOptions(execution.getJobOptions());
job.setAllowConcurrent(execution.getAllowConcurrent());
return job;
}
private boolean finishOwned(ClaimedSysJobExecution claim, int status,
String result, String error) {
try {
if (store.finish(claim, status, result, error)) return true;
log.warn("定时任务终态提交被 fencing 拒绝: executionId={}, status={}",
claim.execution().getId(), status);
} catch (RuntimeException exception) {
log.error("定时任务终态提交失败,保留租约等待恢复: executionId={}, status={}",
claim.execution().getId(), status, exception);
}
metrics.recordLostLease();
return false;
}
private static String serializeResult(Object result, java.math.BigInteger executionId) {
if (result == null) return null;
try {
return JSON.toJSONString(result);
} catch (RuntimeException exception) {
log.warn("定时任务结果无法序列化,仅保留成功状态: executionId={}",
executionId, exception);
return null;
}
}
private void refreshQueueMetrics() {
try {
SysJobQueueSnapshot snapshot = store.queueSnapshot();
metrics.updateQueue(snapshot.pending(), snapshot.running(),
snapshot.oldestBacklogMillis());
} catch (RuntimeException exception) {
log.warn("刷新定时任务队列指标失败", exception);
}
}
private static SysJobExecutionContext toExecutionContext(SysJobLog execution) {
return new SysJobExecutionContext(
execution.getId(),
execution.getExecutionKey(),
execution.getTenantId(),
execution.getAttemptCount() == null ? 1 : execution.getAttemptCount() + 1,
execution.getTriggerSource(),
execution.getInvocationId(),
execution.getScheduledFireTime() == null
? null : execution.getScheduledFireTime().toInstant());
}
@Override
public synchronized void stop() {
if (!running.compareAndSet(true, false)) return;
workers.shutdown();
try {
if (!workers.awaitTermination(properties.getShutdownWaitTimeout().toMillis(),
TimeUnit.MILLISECONDS)) {
abandonActiveExecutions();
workers.shutdownNow();
awaitForcedWorkerShutdown();
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
abandonActiveExecutions();
workers.shutdownNow();
awaitForcedWorkerShutdown();
} finally {
heartbeat.shutdownNow();
}
log.info("定时任务 Worker 已停止: owner={}", owner);
}
@Override
public boolean isRunning() {
return running.get();
}
@Override
public int getPhase() {
return Integer.MAX_VALUE - 100;
}
private static String buildOwner() {
String runtime = ManagementFactory.getRuntimeMXBean().getName();
String value = runtime + '-' + Integer.toHexString(System.identityHashCode(SysJobExecutionWorker.class));
return value.substring(0, Math.min(190, value.length()));
}
private void abandonActiveExecutions() {
active.values().forEach(execution -> execution.abandon().set(true));
}
private void awaitForcedWorkerShutdown() {
try {
if (!workers.awaitTermination(
Math.min(1_000L, properties.getShutdownWaitTimeout().toMillis()),
TimeUnit.MILLISECONDS)) {
log.warn("部分定时任务业务代码忽略中断,将由守护线程退出和租约接管收口: owner={}", owner);
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
}
private record ActiveExecution(ClaimedSysJobExecution claim, Thread thread,
AtomicBoolean abandon) {
private ActiveExecution(ClaimedSysJobExecution claim, Thread thread) {
this(claim, thread, new AtomicBoolean());
}
}
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.job.execution;
/**
* 业务副作用开始前发生的可恢复基础设施故障。
*/
public class SysJobInfrastructureException extends RuntimeException {
public SysJobInfrastructureException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,195 @@
package tech.easyflow.job.execution;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.RecoverableDataAccessException;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.stereotype.Component;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.service.WorkflowJobExecutionService;
import tech.easyflow.system.entity.SysAccount;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Map;
import java.util.Objects;
/** 在 Worker 线程中执行一种 EasyFlow 业务任务。 */
@Component
public class SysJobInvoker {
private final SysJobMapper jobMapper;
private final ApplicationContext applicationContext;
private final WorkflowJobExecutionService workflowExecutionService;
private final SysJobOwnerValidator ownerValidator;
public SysJobInvoker(SysJobMapper jobMapper,
ApplicationContext applicationContext,
WorkflowJobExecutionService workflowExecutionService,
SysJobOwnerValidator ownerValidator) {
this.jobMapper = jobMapper;
this.applicationContext = applicationContext;
this.workflowExecutionService = workflowExecutionService;
this.ownerValidator = ownerValidator;
}
public Object execute(SysJob snapshot) throws Exception {
SysJob current;
SysAccount owner;
try {
current = TenantManager.withoutTenantCondition(
() -> jobMapper.selectOneById(snapshot.getId()));
validateCurrent(snapshot, current);
owner = ownerValidator.requireAvailableOwner(current);
} catch (DataAccessException exception) {
throw classifyPreparationFailure("准备定时任务执行上下文失败", exception);
}
if (Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(snapshot.getJobType())) {
return workflowExecutionService.execute(snapshot, current, owner);
}
if (Integer.valueOf(EnumJobType.SPRING_BEAN.getCode()).equals(snapshot.getJobType())) {
return executeSpringBean(snapshot);
}
if (Integer.valueOf(EnumJobType.JAVA_CLASS.getCode()).equals(snapshot.getJobType())) {
return executeJavaClass(snapshot);
}
throw new IllegalArgumentException("不支持的定时任务类型: " + snapshot.getJobType());
}
private static RuntimeException classifyPreparationFailure(
String message, DataAccessException exception) {
if (exception instanceof TransientDataAccessException
|| exception instanceof RecoverableDataAccessException
|| exception instanceof DataAccessResourceFailureException) {
return new SysJobInfrastructureException(message, exception);
}
return exception;
}
private void validateCurrent(SysJob snapshot, SysJob current) {
if (current == null) {
throw new SysJobCancelledException("定时任务已删除id=" + snapshot.getId());
}
if (!Objects.equals(snapshot.getTenantId(), current.getTenantId())) {
throw new SysJobCancelledException("定时任务租户已变化id=" + snapshot.getId());
}
if (!Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(current.getStatus())) {
throw new SysJobCancelledException("定时任务已停止id=" + snapshot.getId());
}
if (!Objects.equals(snapshot.getJobType(), current.getJobType())) {
throw new SysJobCancelledException("定时任务类型已变化id=" + snapshot.getId());
}
}
private Object executeSpringBean(SysJob job) throws Exception {
String expression = requiredExpression(job, JobConstant.BEAN_METHOD_KEY);
MethodCall call = parse(expression);
Object bean = applicationContext.getBean(call.owner());
Class<?> targetClass = AopUtils.getTargetClass(bean);
Method targetMethod = publicMethod(targetClass, call, false);
Method invocableMethod = AopUtils.selectInvocableMethod(targetMethod, bean.getClass());
return invoke(bean, invocableMethod, call.arguments());
}
private Object executeJavaClass(SysJob job) throws Exception {
String expression = requiredExpression(job, JobConstant.JAVA_METHOD_KEY);
MethodCall call = parse(expression);
Class<?> type = Class.forName(call.owner());
Object target = type.getDeclaredConstructor().newInstance();
return invoke(target, publicMethod(type, call, true), call.arguments());
}
private static Method publicMethod(Class<?> type, MethodCall call,
boolean declaredOnly) throws NoSuchMethodException {
Method method = declaredOnly
? type.getDeclaredMethod(call.method(), call.parameterTypes())
: type.getMethod(call.method(), call.parameterTypes());
if (!Modifier.isPublic(method.getModifiers())) {
throw new NoSuchMethodException("仅允许调用 public 方法: "
+ type.getName() + '.' + call.method());
}
return method;
}
private static Object invoke(Object target, Method method, Object[] arguments)
throws Exception {
try {
return method.invoke(target, arguments);
} catch (InvocationTargetException exception) {
Throwable cause = exception.getTargetException();
if (cause instanceof Exception checked) throw checked;
if (cause instanceof Error error) throw error;
throw exception;
}
}
private static String requiredExpression(SysJob job, String key) {
Map<String, Object> params = job.getJobParams();
Object value = params == null ? null : params.get(key);
if (value == null || !StrUtil.isNotBlank(value.toString())) {
throw new IllegalArgumentException("定时任务缺少执行表达式: " + key);
}
return value.toString().trim();
}
private static MethodCall parse(String expression) {
String before = StrUtil.subBefore(expression, "(", false);
int separator = before.lastIndexOf('.');
if (separator < 1 || !expression.endsWith(")")) {
throw new IllegalArgumentException("非法方法表达式: " + expression);
}
String owner = before.substring(0, separator);
String method = before.substring(separator + 1);
String params = StrUtil.subBetween(expression, "(", ")");
Object[] parsed = parseParams(params);
return new MethodCall(owner, method, (Class<?>[]) parsed[0], (Object[]) parsed[1]);
}
private static Object[] parseParams(String params) {
if (StrUtil.isEmpty(params)) return new Object[]{new Class<?>[0], new Object[0]};
String[] values = params.split(",");
Object[] arguments = new Object[values.length];
Class<?>[] types = new Class<?>[values.length];
for (int i = 0; i < values.length; i++) {
String value = values[i].trim();
if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) {
arguments[i] = value.substring(1, value.length() - 1);
types[i] = String.class;
} else if ("true".equals(value) || "false".equals(value)) {
arguments[i] = Boolean.valueOf(value);
types[i] = Boolean.class;
} else if (value.endsWith("L")) {
arguments[i] = Long.valueOf(value.substring(0, value.length() - 1));
types[i] = Long.class;
} else if (value.endsWith("D")) {
arguments[i] = Double.valueOf(value.substring(0, value.length() - 1));
types[i] = Double.class;
} else if (value.endsWith("F")) {
arguments[i] = Float.valueOf(value.substring(0, value.length() - 1));
types[i] = Float.class;
} else {
arguments[i] = Integer.valueOf(value);
types[i] = Integer.class;
}
}
return new Object[]{types, arguments};
}
private record MethodCall(String owner, String method,
Class<?>[] parameterTypes, Object[] arguments) {
private MethodCall {
parameterTypes = ArrayUtil.isEmpty(parameterTypes) ? new Class<?>[0] : parameterTypes;
arguments = ArrayUtil.isEmpty(arguments) ? new Object[0] : arguments;
}
}
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.job.execution;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Component;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Objects;
/** 定时任务服务端归属账号校验器。 */
@Component
public class SysJobOwnerValidator {
private final SysAccountService accountService;
public SysJobOwnerValidator(SysAccountService accountService) {
this.accountService = accountService;
}
/**
* 按数据库当前状态校验任务归属账号。
*
* <p>调度 Worker 没有登录租户上下文,因此关闭 ORM 租户条件读取账号,
* 再显式比较任务与账号租户,避免跨租户账号被用于高权限任务执行。</p>
*/
public SysAccount requireAvailableOwner(SysJob job) {
BigInteger accountId = job.getCreatedBy();
if (accountId == null) {
throw new IllegalStateException("定时任务缺少服务端归属账号id=" + job.getId());
}
SysAccount account = TenantManager.withoutTenantCondition(
() -> accountService.getById(accountId));
if (account == null) {
throw new IllegalStateException("定时任务归属账号不存在id=" + accountId);
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())) {
throw new IllegalStateException("定时任务归属账号未启用id=" + accountId);
}
if (!Objects.equals(job.getTenantId(), account.getTenantId())) {
throw new IllegalStateException("定时任务与归属账号租户不一致id=" + job.getId());
}
return account;
}
}

View File

@@ -0,0 +1,5 @@
package tech.easyflow.job.execution;
/** 当前数据库执行队列的全局状态。 */
public record SysJobQueueSnapshot(long pending, long running, long oldestBacklogMillis) {
}

View File

@@ -1,72 +0,0 @@
package tech.easyflow.job.job;
import cn.hutool.core.exceptions.ExceptionUtil;
import com.alibaba.fastjson.JSON;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.easyflow.common.constant.enums.EnumJobExecStatus;
import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.service.SysJobLogService;
import java.util.Date;
public abstract class BaseQuartzJob implements Job {
protected Logger log = LoggerFactory.getLogger(getClass());
private final static ThreadLocal<Date> TIME_RECORD = new ThreadLocal<>();
@Override
public void execute(JobExecutionContext ctx) throws JobExecutionException {
SysJob job = (SysJob) ctx.getMergedJobDataMap().get(JobConstant.JOB_MAP_BEAN_NAME);
try {
beforeExecute(ctx, job);
Object result = doExecute(ctx, job);
afterExecute(ctx, job, result, null);
} catch (Exception e) {
log.error("quartz 任务执行报错:", e);
afterExecute(ctx, job, null, e);
}
}
protected void beforeExecute(JobExecutionContext ctx, SysJob job) {
TIME_RECORD.set(new Date());
}
protected void afterExecute(JobExecutionContext ctx, SysJob job, Object result, Exception e) {
Date startTime = TIME_RECORD.get();
TIME_RECORD.remove();
Date endTime = new Date();
SysJobLog sysJobLog = new SysJobLog();
sysJobLog.setJobId(job.getId());
sysJobLog.setJobName(job.getJobName());
sysJobLog.setStatus(EnumJobExecStatus.SUCCESS.getCode());
sysJobLog.setJobParams(job.getJobParams());
if (result != null) {
sysJobLog.setJobResult(JSON.toJSONString(result));
}
if (e != null) {
String message = ExceptionUtil.getRootCauseMessage(e);
if (message.length() > 1000) {
message = message.substring(0, 1000);
}
sysJobLog.setErrorInfo(message);
sysJobLog.setStatus(EnumJobExecStatus.FAIL.getCode());
}
sysJobLog.setStartTime(startTime);
sysJobLog.setEndTime(endTime);
sysJobLog.setCreated(new Date());
SysJobLogService service = SpringContextUtil.getBean(SysJobLogService.class);
service.save(sysJobLog);
}
protected abstract Object doExecute(JobExecutionContext ctx, SysJob job) throws Exception;
}

View File

@@ -2,12 +2,15 @@ package tech.easyflow.job.job;
public interface JobConstant {
String JOB_GROUP = "easyflow";
String JOB_MAP_BEAN_NAME = "jobMapBean";
String BEAN_METHOD_KEY = "beanMethod";
String JAVA_METHOD_KEY = "javaMethod";
String WORKFLOW_KEY = "workflowId";
String WORKFLOW_PARAMS_KEY = "workflowParams";
/** 注入工作流运行参数的定时任务业务幂等键。 */
String EXECUTION_KEY = "_easyflowJobExecutionKey";
/** Quartz JobData 中用于隔离旧定义触发的任务代际。 */
String SCHEDULE_GENERATION = "scheduleGeneration";
String ACCOUNT_ID = "accountId";
}

View File

@@ -1,16 +0,0 @@
package tech.easyflow.job.job;
import org.quartz.JobExecutionContext;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.util.JobUtil;
/**
* 可并发执行
*/
public class QuartzJob extends BaseQuartzJob {
@Override
protected Object doExecute(JobExecutionContext ctx, SysJob job) throws Exception {
return JobUtil.execute(job);
}
}

View File

@@ -1,18 +0,0 @@
package tech.easyflow.job.job;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.util.JobUtil;
/**
* 禁止并发执行
*/
@DisallowConcurrentExecution
public class QuartzJobNoConcurrent extends BaseQuartzJob {
@Override
protected Object doExecute(JobExecutionContext ctx, SysJob job) throws Exception {
return JobUtil.execute(job);
}
}

View File

@@ -1,8 +1,14 @@
package tech.easyflow.job.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import tech.easyflow.job.entity.SysJobLog;
import java.math.BigInteger;
/**
* 系统任务日志 映射层。
*
@@ -11,4 +17,158 @@ import tech.easyflow.job.entity.SysJobLog;
*/
public interface SysJobLogMapper extends BaseMapper<SysJobLog> {
@Select("SELECT id FROM tb_sys_job_log WHERE execution_key=#{executionKey} LIMIT 1")
BigInteger selectIdByExecutionKey(@Param("executionKey") String executionKey);
@Select("SELECT id FROM tb_sys_job_log WHERE job_id=#{jobId} AND status=2 "
+ "ORDER BY id FOR UPDATE")
java.util.List<BigInteger> selectPendingIdsByJobIdForUpdate(
@Param("jobId") BigInteger jobId);
@Select("SELECT id FROM tb_sys_job_log WHERE status=3 "
+ "AND lease_until<=CURRENT_TIMESTAMP(3) "
+ "ORDER BY lease_until, id LIMIT 1 FOR UPDATE SKIP LOCKED")
BigInteger selectExpiredClaimCandidateId();
/**
* 无锁探测是否存在已过期执行。过期范围为空时若直接使用 {@code FOR UPDATE}
* MySQL 仍可能锁住状态索引的末端间隙,随后 PENDING -> RUNNING 的索引迁移会在
* 多 Worker 间形成 insert-intention 死锁。
*/
@Select("SELECT id FROM tb_sys_job_log WHERE status=3 "
+ "AND lease_until<=CURRENT_TIMESTAMP(3) "
+ "ORDER BY lease_until, id LIMIT 1")
BigInteger selectExpiredClaimCandidateIdWithoutLock();
@Select("SELECT id FROM tb_sys_job_log WHERE status=2 "
+ "AND next_retry_time<=CURRENT_TIMESTAMP(3) "
+ "ORDER BY next_retry_time, id LIMIT 1 FOR UPDATE SKIP LOCKED")
BigInteger selectPendingClaimCandidateId();
/**
* 先用最小投影锁定候选行,再通过 BaseMapper 实体 ResultMap 读取快照。
* 这样既保留 MySQL {@code SKIP LOCKED},又避免注解式 {@code SELECT *}
* 丢失 {@code job_id}、JSON 快照等下划线字段映射。
*/
default SysJobLog selectExpiredClaimCandidate() {
BigInteger id = selectExpiredClaimCandidateId();
return id == null ? null : selectOneById(id);
}
/** 同 {@link #selectExpiredClaimCandidate()}。 */
default SysJobLog selectPendingClaimCandidate() {
BigInteger id = selectPendingClaimCandidateId();
return id == null ? null : selectOneById(id);
}
@Select("SELECT id FROM tb_sys_job_log WHERE job_id=#{jobId} AND id<>#{excludedId} "
+ "AND status=3 AND lease_until>CURRENT_TIMESTAMP(3) ORDER BY id LIMIT 1 FOR UPDATE")
BigInteger selectOtherActiveForUpdate(@Param("jobId") BigInteger jobId,
@Param("excludedId") BigInteger excludedId);
@Update("UPDATE tb_sys_job_log SET status=3, lease_owner=#{owner}, "
+ "execution_token=#{token}, "
+ "lease_until=TIMESTAMPADD(MICROSECOND, #{leaseMicros}, CURRENT_TIMESTAMP(3)), "
+ "heartbeat_time=CURRENT_TIMESTAMP(3), "
+ "attempt_count=attempt_count+1, next_retry_time=NULL, "
+ "start_time=COALESCE(start_time, CURRENT_TIMESTAMP(3)), end_time=NULL, "
+ "error_info=NULL, version=version+1 "
+ "WHERE id=#{id} AND status=2 AND next_retry_time<=CURRENT_TIMESTAMP(3)")
int claimPending(@Param("id") BigInteger id,
@Param("owner") String owner,
@Param("token") String token,
@Param("leaseMicros") long leaseMicros);
@Update("UPDATE tb_sys_job_log SET lease_owner=#{owner}, execution_token=#{token}, "
+ "lease_until=TIMESTAMPADD(MICROSECOND, #{leaseMicros}, CURRENT_TIMESTAMP(3)), "
+ "heartbeat_time=CURRENT_TIMESTAMP(3), attempt_count=attempt_count+1, "
+ "next_retry_time=NULL, error_info=NULL, version=version+1 "
+ "WHERE id=#{id} AND status=3 AND lease_until<=CURRENT_TIMESTAMP(3)")
int claimExpired(@Param("id") BigInteger id,
@Param("owner") String owner,
@Param("token") String token,
@Param("leaseMicros") long leaseMicros);
@Update("UPDATE tb_sys_job_log SET "
+ "next_retry_time=TIMESTAMPADD(MICROSECOND, #{retryMicros}, CURRENT_TIMESTAMP(3)) "
+ "WHERE id=#{id} AND status=2")
int deferPending(@Param("id") BigInteger id,
@Param("retryMicros") long retryMicros);
@Update("UPDATE tb_sys_job_log SET status=2, lease_owner=NULL, execution_token=NULL, "
+ "lease_until=NULL, heartbeat_time=CURRENT_TIMESTAMP(3), "
+ "next_retry_time=TIMESTAMPADD(MICROSECOND, #{retryMicros}, CURRENT_TIMESTAMP(3)), "
+ "version=version+1 WHERE id=#{id} AND status=3 "
+ "AND lease_until<=CURRENT_TIMESTAMP(3)")
int releaseExpiredForRetry(@Param("id") BigInteger id,
@Param("retryMicros") long retryMicros);
@Update("UPDATE tb_sys_job_log SET status=2, lease_owner=NULL, execution_token=NULL, "
+ "lease_until=NULL, heartbeat_time=CURRENT_TIMESTAMP(3), "
+ "next_retry_time=TIMESTAMPADD(MICROSECOND, #{retryMicros}, CURRENT_TIMESTAMP(3)), "
+ "error_info=#{errorInfo}, version=version+1 WHERE id=#{id} AND status=3 "
+ "AND lease_owner=#{owner} AND execution_token=#{token}")
int releaseOwnedForRetry(@Param("id") BigInteger id,
@Param("owner") String owner,
@Param("token") String token,
@Param("retryMicros") long retryMicros,
@Param("errorInfo") String errorInfo);
@Update("UPDATE tb_sys_job_log SET "
+ "lease_until=TIMESTAMPADD(MICROSECOND, #{leaseMicros}, CURRENT_TIMESTAMP(3)), "
+ "heartbeat_time=CURRENT_TIMESTAMP(3) "
+ "WHERE id=#{id} AND status=3 AND lease_owner=#{owner} AND execution_token=#{token}")
int renewLease(@Param("id") BigInteger id,
@Param("owner") String owner,
@Param("token") String token,
@Param("leaseMicros") long leaseMicros);
@Update("UPDATE tb_sys_job_log SET status=#{status}, job_result=#{jobResult}, "
+ "error_info=#{errorInfo}, end_time=CURRENT_TIMESTAMP(3), lease_owner=NULL, "
+ "execution_token=NULL, lease_until=NULL, heartbeat_time=CURRENT_TIMESTAMP(3), "
+ "version=version+1 "
+ "WHERE id=#{id} AND status=3 AND lease_owner=#{owner} AND execution_token=#{token}")
int finishOwned(@Param("id") BigInteger id,
@Param("owner") String owner,
@Param("token") String token,
@Param("status") int status,
@Param("jobResult") String jobResult,
@Param("errorInfo") String errorInfo);
@Update("UPDATE tb_sys_job_log SET status=4, error_info=#{errorInfo}, "
+ "end_time=CURRENT_TIMESTAMP(3), lease_owner=NULL, execution_token=NULL, "
+ "lease_until=NULL, heartbeat_time=CURRENT_TIMESTAMP(3), version=version+1 "
+ "WHERE id=#{id} AND status=3 AND lease_until<=CURRENT_TIMESTAMP(3)")
int markExpiredDead(@Param("id") BigInteger id,
@Param("errorInfo") String errorInfo);
@Update("UPDATE tb_sys_job_log SET status=4, error_info=#{errorInfo}, "
+ "end_time=CURRENT_TIMESTAMP(3), next_retry_time=NULL, "
+ "heartbeat_time=CURRENT_TIMESTAMP(3), version=version+1 "
+ "WHERE id=#{id} AND status=2")
int markPendingDead(@Param("id") BigInteger id,
@Param("errorInfo") String errorInfo);
@Update("UPDATE tb_sys_job_log SET status=5, error_info=#{reason}, "
+ "end_time=CURRENT_TIMESTAMP(3), "
+ "next_retry_time=NULL, version=version+1 WHERE job_id=#{jobId} AND status=2")
int cancelPendingByJobId(@Param("jobId") BigInteger jobId,
@Param("reason") String reason);
@Select("SELECT COUNT(1) FROM tb_sys_job_log WHERE status=2")
long countPending();
@Select("SELECT COUNT(1) FROM tb_sys_job_log WHERE status=3")
long countRunning();
@Select("SELECT COALESCE(TIMESTAMPDIFF(MICROSECOND, MIN(scheduled_fire_time), "
+ "CURRENT_TIMESTAMP(3)) DIV 1000, 0) FROM tb_sys_job_log WHERE status=2")
long selectOldestPendingMillis();
@Select("SELECT COUNT(1) FROM tb_sys_job_log WHERE id=#{id} AND status IN (2,3)")
int countActiveById(@Param("id") BigInteger id);
@Delete("DELETE FROM tb_sys_job_log WHERE id=#{id} AND status NOT IN (2,3)")
int deleteTerminalById(@Param("id") BigInteger id);
}

View File

@@ -1,8 +1,14 @@
package tech.easyflow.job.mapper;
import com.mybatisflex.core.BaseMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import tech.easyflow.job.entity.SysJob;
import java.math.BigInteger;
import java.util.List;
/**
* 系统任务表 映射层。
*
@@ -11,4 +17,31 @@ import tech.easyflow.job.entity.SysJob;
*/
public interface SysJobMapper extends BaseMapper<SysJob> {
/**
* 使用 BaseMapper 的实体 ResultMap 读取并锁定任务。
*
* <p>不能用注解式 {@code SELECT *}:新增下划线列时它不会复用
* MyBatis-Flex 生成的实体映射,曾导致 {@code schedule_generation}
* 在运行态被读取为 {@code null}。</p>
*/
default SysJob selectByIdForUpdate(BigInteger id) {
return selectOneByQuery(QueryWrapper.create()
.eq(SysJob::getId, id)
.forUpdate());
}
@Update("UPDATE tb_sys_job SET status=#{runningStatus}, "
+ "schedule_generation=schedule_generation+1 "
+ "WHERE id=#{id} AND status<>#{runningStatus}")
int startNextGeneration(@Param("id") BigInteger id,
@Param("runningStatus") int runningStatus);
/** 使用实体 ResultMap 做 keyset 分页,确保所有调度字段完整映射。 */
default List<SysJob> selectReconciliationPage(BigInteger lastId, int limit) {
return selectListByQuery(QueryWrapper.create()
.gt(SysJob::getId, lastId)
.orderBy(SysJob::getId, true)
.limit(limit));
}
}

View File

@@ -0,0 +1,82 @@
package tech.easyflow.job.schedule;
import com.easyagents.scheduler.ConcurrencyPolicy;
import com.easyagents.scheduler.CronSchedulePlan;
import com.easyagents.scheduler.MisfirePolicy;
import com.easyagents.scheduler.ScheduleDefinition;
import com.easyagents.scheduler.ScheduleId;
import com.easyagents.scheduler.ScheduleService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.execution.EasyFlowScheduleHandler;
import tech.easyflow.job.job.JobConstant;
import java.time.ZoneId;
import java.util.Map;
import java.util.UUID;
/** EasyFlow 任务定义到 easy-agents 调度模型的唯一映射入口。 */
@Component
public class SysJobScheduleAdapter {
static final String NAMESPACE = "easyflow.job";
private final ScheduleService scheduleService;
private final ZoneId zoneId;
public SysJobScheduleAdapter(ScheduleService scheduleService,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String zoneId) {
this.scheduleService = scheduleService;
this.zoneId = ZoneId.of(zoneId);
}
public void replace(SysJob job) {
scheduleService.replace(toDefinition(job));
}
public void delete(java.math.BigInteger jobId) {
scheduleService.delete(scheduleId(jobId));
}
public String triggerNow(java.math.BigInteger jobId) {
String invocationId = UUID.randomUUID().toString();
scheduleService.triggerNow(scheduleId(jobId), invocationId, Map.of());
return invocationId;
}
ScheduleDefinition toDefinition(SysJob job) {
Long generation = job.getScheduleGeneration();
if (generation == null || generation < 0L) {
throw new IllegalArgumentException("任务调度代际非法: " + generation);
}
return new ScheduleDefinition(
scheduleId(job.getId()),
EasyFlowScheduleHandler.CODE,
new CronSchedulePlan(job.getCronExpression(), zoneId),
toMisfirePolicy(job.getMisfirePolicy()),
Integer.valueOf(1).equals(job.getAllowConcurrent())
? ConcurrencyPolicy.ALLOW : ConcurrencyPolicy.DISALLOW,
true,
Map.of(
"jobId", job.getId().toString(),
JobConstant.SCHEDULE_GENERATION, generation.toString()),
job.getJobName());
}
private static MisfirePolicy toMisfirePolicy(Integer policy) {
if (Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(policy)) {
return MisfirePolicy.FIRE_ONCE_NOW;
}
if (Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(policy)) {
return MisfirePolicy.SKIP;
}
throw new IllegalArgumentException("不支持的 Misfire 策略: " + policy);
}
private static ScheduleId scheduleId(java.math.BigInteger jobId) {
if (jobId == null) throw new IllegalArgumentException("jobId must not be null");
return new ScheduleId(NAMESPACE, jobId.toString());
}
}

View File

@@ -0,0 +1,62 @@
package tech.easyflow.job.schedule;
import com.mybatisflex.core.tenant.TenantManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.service.SysJobService;
import java.math.BigInteger;
import java.util.List;
/** 启动时一次性修复业务定义与 Quartz 投影的跨事务漂移。 */
@Component
public class SysJobScheduleReconciler {
private static final Logger log = LoggerFactory.getLogger(SysJobScheduleReconciler.class);
private static final int PAGE_SIZE = 200;
private final SysJobMapper mapper;
private final SysJobService jobService;
private final SysJobExecutionProperties properties;
public SysJobScheduleReconciler(SysJobMapper mapper,
SysJobService jobService,
SysJobExecutionProperties properties) {
this.mapper = mapper;
this.jobService = jobService;
this.properties = properties;
}
@EventListener(ApplicationReadyEvent.class)
public void reconcile() {
if (!properties.isEnabled()) return;
BigInteger lastId = BigInteger.ZERO;
int reconciled = 0;
int failed = 0;
while (true) {
BigInteger pageStartId = lastId;
List<SysJob> jobs = TenantManager.withoutTenantCondition(
() -> mapper.selectReconciliationPage(pageStartId, PAGE_SIZE));
for (SysJob job : jobs) {
try {
// syncJob 会按 jobId 加分布式锁,并在锁内重新读取最新定义。
jobService.syncJob(job.getId());
reconciled++;
} catch (RuntimeException exception) {
failed++;
log.error("定时任务启动对账失败: jobId={}", job.getId(), exception);
}
}
if (jobs.size() < PAGE_SIZE) break;
// Keyset 分页不依赖已处理行仍然存在,避免对账过程中并发删除导致跳行。
lastId = jobs.get(jobs.size() - 1).getId();
}
log.info("定时任务启动对账完成: succeeded={}, failed={}", reconciled, failed);
}
}

View File

@@ -3,6 +3,9 @@ package tech.easyflow.job.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.job.entity.SysJobLog;
import java.io.Serializable;
import java.util.Collection;
/**
* 系统任务日志 服务层。
*
@@ -11,4 +14,5 @@ import tech.easyflow.job.entity.SysJobLog;
*/
public interface SysJobLogService extends IService<SysJobLog> {
void requireTerminal(Collection<Serializable> ids);
}

View File

@@ -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.Date;
import java.util.List;
/**
@@ -22,12 +23,20 @@ public interface SysJobService extends IService<SysJob> {
void addJob(SysJob job);
void syncJob(BigInteger id);
void updateJobDefinition(SysJob job);
void deleteJob(Collection<Serializable> ids);
void startJob(BigInteger id);
void stopJob(BigInteger id);
String triggerNow(BigInteger id);
List<Date> nextFireTimes(String cronExpression, int limit);
/**
* 查询引用指定工作流的定时任务。
*

View File

@@ -3,18 +3,23 @@ package tech.easyflow.job.service;
import cn.hutool.core.bean.BeanUtil;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.RecoverableDataAccessException;
import org.springframework.dao.TransientDataAccessException;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.execution.SysJobExecutionContextHolder;
import tech.easyflow.job.execution.SysJobInfrastructureException;
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.math.BigInteger;
import java.util.Map;
@@ -23,17 +28,13 @@ import java.util.Objects;
/**
* 工作流定时任务执行服务。
*
* <p>每次触发都重新加载任务账号和工作流,并按服务端记录恢复执行主体及重新授权。</p>
* <p>任务入口已按数据库当前状态复核任务账号;本服务继续检查工作流权限。
* 工作流引用与运行参数使用触发登记时的账本快照,避免积压期间的普通编辑
* 改变既有 execution。</p>
*/
@Service
public class WorkflowJobExecutionService {
/** 定时任务服务。 */
private final SysJobService sysJobService;
/** 系统账号服务。 */
private final SysAccountService sysAccountService;
/** 工作流使用权限校验服务。 */
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
@@ -43,18 +44,12 @@ public class WorkflowJobExecutionService {
/**
* 创建工作流定时任务执行服务。
*
* @param sysJobService 定时任务服务
* @param sysAccountService 系统账号服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param chainExecutor 工作流执行器
*/
public WorkflowJobExecutionService(
SysJobService sysJobService,
SysAccountService sysAccountService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
ChainExecutor chainExecutor) {
this.sysJobService = sysJobService;
this.sysAccountService = sysAccountService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.chainExecutor = chainExecutor;
}
@@ -63,31 +58,45 @@ public class WorkflowJobExecutionService {
* 使用当前数据库状态执行工作流定时任务。
*
* @param scheduledJob Quartz 中保存的任务快照
* @param currentJob 数据库当前任务定义
* @param owner 已完成实时复核的任务归属账号
* @return 工作流执行结果
* @throws IllegalStateException 任务、账号或租户状态非法时抛出
*/
public Object execute(SysJob scheduledJob) {
public Object execute(SysJob scheduledJob, SysJob currentJob, SysAccount owner) {
if (scheduledJob == null || scheduledJob.getId() == null) {
throw new IllegalStateException("定时任务不存在或缺少ID");
}
return TenantManager.withoutTenantCondition(
() -> executeWithoutTenantCondition(
scheduledJob.getId(),
scheduledJob.getTenantId()));
PreparedWorkflowExecution prepared;
try {
prepared = prepare(scheduledJob, currentJob, owner);
} catch (DataAccessException exception) {
if (exception instanceof TransientDataAccessException
|| exception instanceof RecoverableDataAccessException
|| exception instanceof DataAccessResourceFailureException) {
throw new SysJobInfrastructureException(
"准备工作流定时任务执行上下文失败", exception);
}
throw exception;
}
return chainExecutor.execute(
PublishedWorkflowDefinitionIds.published(prepared.workflowId().toString()),
prepared.parameters());
}
/**
* 在已关闭 ORM 租户条件的作用域中执行任务,并显式完成租户边界校验
* 组装执行参数,并再次验证入口传入的当前状态与租户边界。
*
* @param jobId 定时任务 ID
* @param scheduledTenantId Quartz 任务快照中的租户 ID
* @param scheduledJob 触发登记时保存的任务快照
* @param job 数据库当前任务定义
* @param owner 已完成实时复核的任务归属账号
* @return 工作流执行结果
* @throws IllegalStateException 任务、账号或租户状态非法时抛出
*/
private Object executeWithoutTenantCondition(
BigInteger jobId,
BigInteger scheduledTenantId) {
SysJob job = sysJobService.getById(jobId);
private PreparedWorkflowExecution prepare(
SysJob scheduledJob, SysJob job, SysAccount owner) {
BigInteger jobId = scheduledJob.getId();
BigInteger scheduledTenantId = scheduledJob.getTenantId();
if (job == null) {
throw new IllegalStateException("定时任务不存在或已删除id=" + jobId);
}
@@ -102,44 +111,38 @@ public class WorkflowJobExecutionService {
if (!SysJobWorkflowReferenceSupport.isWorkflowJob(job)) {
throw new IllegalStateException("定时任务类型已变更id=" + jobId);
}
if (!SysJobWorkflowReferenceSupport.isWorkflowJob(scheduledJob)) {
throw new IllegalStateException("定时任务快照类型非法id=" + jobId);
}
SysAccount account = requireAvailableOwner(job);
SysAccount account = requirePreparedOwner(job, owner);
LoginAccount loginAccount = new LoginAccount();
BeanUtil.copyProperties(account, loginAccount);
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(job);
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(scheduledJob);
workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
loginAccount,
"定时任务关联的工作流不存在、已禁用或无权运行");
"定时任务关联的工作流不存在、未发布或无权运行");
JSONObject workflowParams = resolveWorkflowParams(job.getJobParams());
JSONObject workflowParams = resolveWorkflowParams(scheduledJob.getJobParams());
SysJobExecutionContextHolder.current().ifPresent(context ->
workflowParams.put(JobConstant.EXECUTION_KEY, context.executionKey()));
workflowParams.put(Constants.LOGIN_USER_KEY, loginAccount);
return chainExecutor.execute(workflowId.toString(), workflowParams);
return new PreparedWorkflowExecution(workflowId, workflowParams);
}
/**
* 获取任务创建账号并校验账号仍可用于执行任务。
*
* @param job 当前数据库中的定时任务
* @return 可用的任务创建账号
* @throws IllegalStateException 创建账号缺失、禁用或跨租户时抛出
*/
private SysAccount requireAvailableOwner(SysJob job) {
BigInteger accountId = job.getCreatedBy();
if (accountId == null) {
throw new IllegalStateException("定时任务缺少服务端归属账号id=" + job.getId());
/** 验证调用方传入的是当前任务已完成数据库复核的归属账号。 */
private static SysAccount requirePreparedOwner(SysJob job, SysAccount owner) {
if (owner == null || !Objects.equals(job.getCreatedBy(), owner.getId())) {
throw new IllegalStateException("定时任务归属账号校验结果无效id=" + job.getId());
}
SysAccount account = sysAccountService.getById(accountId);
if (account == null) {
throw new IllegalStateException("定时任务归属账号不存在id=" + accountId);
if (!EnumDataStatus.AVAILABLE.getCode().equals(owner.getStatus())) {
throw new IllegalStateException("定时任务归属账号未启用id=" + owner.getId());
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())) {
throw new IllegalStateException("定时任务归属账号未启用id=" + accountId);
}
if (!Objects.equals(job.getTenantId(), account.getTenantId())) {
if (!Objects.equals(job.getTenantId(), owner.getTenantId())) {
throw new IllegalStateException("定时任务与归属账号租户不一致id=" + job.getId());
}
return account;
return owner;
}
/**
@@ -156,4 +159,7 @@ public class WorkflowJobExecutionService {
.getJSONObject(JobConstant.WORKFLOW_PARAMS_KEY);
return params == null ? new JSONObject() : new JSONObject(params);
}
private record PreparedWorkflowExecution(BigInteger workflowId, JSONObject parameters) {
}
}

View File

@@ -6,6 +6,10 @@ import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.mapper.SysJobLogMapper;
import tech.easyflow.job.service.SysJobLogService;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
/**
* 系统任务日志 服务层实现。
*
@@ -15,4 +19,13 @@ import tech.easyflow.job.service.SysJobLogService;
@Service
public class SysJobLogServiceImpl extends ServiceImpl<SysJobLogMapper, SysJobLog> implements SysJobLogService {
@Override
public void requireTerminal(Collection<Serializable> ids) {
if (ids == null) return;
for (Serializable id : ids) {
if (mapper.countActiveById(new BigInteger(id.toString())) > 0) {
throw new IllegalStateException("等待执行或执行中的任务记录不能删除id=" + id);
}
}
}
}

View File

@@ -1,175 +1,368 @@
package tech.easyflow.job.service.impl;
import com.easyagents.scheduler.CronSchedulePlan;
import com.easyagents.scheduler.ScheduleService;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
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;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.job.QuartzJob;
import tech.easyflow.job.job.QuartzJobNoConcurrent;
import tech.easyflow.job.execution.SysJobExecutionStore;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.schedule.SysJobScheduleAdapter;
import tech.easyflow.job.service.SysJobService;
import tech.easyflow.job.support.SysJobWorkflowReferenceSupport;
import tech.easyflow.job.util.JobUtil;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.time.Duration;
import java.time.ZoneId;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/**
* 系统任务表 服务层实现。
*
* @author xiaoma
* @since 2025-05-20
*/
/** 系统任务管理及 Quartz 投影协调服务。 */
@Service
public class SysJobServiceImpl extends ServiceImpl<SysJobMapper, SysJob> implements SysJobService {
public class SysJobServiceImpl extends ServiceImpl<SysJobMapper, SysJob> implements SysJobService {
private static final Logger log = LoggerFactory.getLogger(SysJobServiceImpl.class);
private static final String JOB_LOCK_KEY_PREFIX = "easyflow:lock:job:";
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
protected Logger log = LoggerFactory.getLogger(SysJobServiceImpl.class);
private final SysJobScheduleAdapter scheduleAdapter;
private final SysJobExecutionStore executionStore;
private final ScheduleService scheduleService;
private final RedisLockExecutor redisLockExecutor;
private final TransactionTemplate managementTransaction;
private final Semaphore managementPermits;
private final ZoneId zoneId;
@Resource
private Scheduler scheduler;
@Resource
private RedisLockExecutor redisLockExecutor;
public SysJobServiceImpl(SysJobScheduleAdapter scheduleAdapter,
SysJobExecutionStore executionStore,
ScheduleService scheduleService,
RedisLockExecutor redisLockExecutor,
PlatformTransactionManager transactionManager,
SysJobExecutionProperties executionProperties,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String zoneId) {
this.scheduleAdapter = scheduleAdapter;
this.executionStore = executionStore;
this.scheduleService = scheduleService;
this.redisLockExecutor = redisLockExecutor;
this.managementTransaction = new TransactionTemplate(transactionManager);
this.managementTransaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
this.managementPermits = new Semaphore(
executionProperties.getManagementCommandConcurrency(), true);
this.zoneId = ZoneId.of(zoneId);
}
@Override
public void test() {
System.out.println("java bean 动态执行");
// 供 Spring Bean 类型定时任务进行开发环境验证。
}
@Override
public void testParam(String a, Boolean b, Integer c) {
System.out.println("动态执行spring bean,执行参数:" + "a="+ a + ",b="+ b + ",c="+ c);
// 供 Spring Bean 类型定时任务进行参数绑定验证。
}
@Override
public void addJob(SysJob job) {
Integer allowConcurrent = job.getAllowConcurrent();
Class<? extends Job> jobClass = allowConcurrent == 1 ? QuartzJob.class : QuartzJobNoConcurrent.class;
JobDetail jobDetail = JobBuilder.newJob(jobClass)
.withIdentity(JobUtil.getJobKey(job))
.build();
jobDetail.getJobDataMap().put(JobConstant.JOB_MAP_BEAN_NAME, job);
CronScheduleBuilder cron = CronScheduleBuilder.cronSchedule(job.getCronExpression());
Integer misfirePolicy = job.getMisfirePolicy();
if (EnumMisfirePolicy.MISFIRE_DO_NOTHING.getCode() == misfirePolicy) {
cron.withMisfireHandlingInstructionDoNothing();
}
if (EnumMisfirePolicy.MISFIRE_FIRE_AND_PROCEED.getCode() == misfirePolicy) {
cron.withMisfireHandlingInstructionFireAndProceed();
}
if (EnumMisfirePolicy.MISFIRE_IGNORE_MISFIRES.getCode() == misfirePolicy) {
cron.withMisfireHandlingInstructionIgnoreMisfires();
if (job == null || job.getId() == null) {
throw new IllegalArgumentException("任务及任务ID不能为空");
}
syncJob(job.getId());
}
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(JobUtil.getTriggerKey(job))
.withSchedule(cron).build();
@Override
public void syncJob(BigInteger id) {
withJobCommandLock(id, () -> {
if (!projectCurrentStateOrStop(id)) {
cancelPendingBestEffort(id, "任务已停止");
}
return null;
});
}
try {
scheduler.scheduleJob(jobDetail,trigger);
} catch (SchedulerException e) {
log.error("启动任务失败:", e);
throw new RuntimeException(e);
@Override
public void updateJobDefinition(SysJob update) {
if (update == null || update.getId() == null) {
throw new IllegalArgumentException("任务及任务ID不能为空");
}
BigInteger id = update.getId();
withJobCommandLock(id, () -> {
inManagementTransaction(() -> {
SysJob current = requireJobForUpdate(id);
long generation = nextScheduleGeneration(current);
// 状态、归属和代际都是服务端维护字段。必须以行锁下的最终值覆盖
// 请求快照,避免普通编辑越过 start/stop 或把并发 STOP 写回 RUNNING。
update.setTenantId(current.getTenantId());
update.setDeptId(current.getDeptId());
update.setCreated(current.getCreated());
update.setCreatedBy(current.getCreatedBy());
update.setStatus(current.getStatus());
update.setScheduleGeneration(generation);
if (!updateById(update)) {
throw new IllegalStateException("定时任务更新失败id=" + id);
}
return null;
});
projectCurrentStateOrStop(id);
return null;
});
}
@Override
public void deleteJob(Collection<Serializable> ids) {
try {
for (Serializable id : ids) {
SysJob sysJob = new SysJob();
sysJob.setId(new BigInteger(id.toString()));
scheduler.deleteJob(JobUtil.getJobKey(sysJob));
}
} catch (SchedulerException e) {
log.error("删除任务失败:", e);
throw new RuntimeException(e);
if (ids == null) return;
List<BigInteger> jobIds = ids.stream()
.map(id -> new BigInteger(id.toString()))
.toList();
// 在产生任何 Quartz 或跨租户账本副作用前,先按当前租户完整校验全部任务。
jobIds.forEach(this::requireJob);
for (BigInteger jobId : jobIds) {
withJobCommandLock(jobId, () -> {
// 分三段收口:业务先 STOPQuartz 投影删除成功后再物理删除。
// 任一阶段失败都只会留下可重试的 STOP 行,不会留下 RUNNING 无投影。
inManagementTransaction(() -> {
SysJob job = requireJobForUpdate(jobId);
if (!Integer.valueOf(EnumJobStatus.STOP.getCode()).equals(job.getStatus())) {
SysJob stopped = new SysJob();
stopped.setId(jobId);
stopped.setStatus(EnumJobStatus.STOP.getCode());
if (!updateById(stopped)) {
throw new IllegalStateException("定时任务停止失败id=" + jobId);
}
}
return null;
});
try {
projectCurrentStateOrStop(jobId);
inManagementTransaction(() -> {
SysJob current = requireJobForUpdate(jobId);
if (!Integer.valueOf(EnumJobStatus.STOP.getCode())
.equals(current.getStatus())) {
throw new IllegalStateException(
"定时任务状态已变化取消删除id=" + jobId);
}
if (!removeById(jobId)) {
throw new IllegalStateException("定时任务删除失败id=" + jobId);
}
return null;
});
} finally {
cancelPendingBestEffort(jobId, "任务已删除或已停止");
}
return null;
});
}
}
@Override
public void startJob(BigInteger id) {
redisLockExecutor.executeWithLock(JOB_LOCK_KEY_PREFIX + id, LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIMEOUT, () -> {
SysJob sysJob = this.getById(id);
if (sysJob == null) {
throw new IllegalStateException("任务不存在id=" + id);
}
try {
JobKey jobKey = JobUtil.getJobKey(sysJob);
if (!scheduler.checkExists(jobKey)) {
addJob(sysJob);
withJobCommandLock(id, () -> {
// STOP 期间可能因数据库瞬时故障遗留上一代 PENDING。重新进入 RUNNING
// 前必须强制清理;失败则拒绝启动,避免旧 fire 被新一代状态放行。
requireJob(id);
executionStore.cancelPending(id, "任务重新启动,取消上一代待执行记录");
inManagementTransaction(() -> {
SysJob current = requireJobForUpdate(id);
if (!Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(current.getStatus())) {
if (getMapper().startNextGeneration(
id, EnumJobStatus.RUNNING.getCode()) != 1) {
throw new IllegalStateException("定时任务启动失败id=" + id);
}
}
if (!Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(sysJob.getStatus())) {
SysJob update = new SysJob();
update.setId(id);
update.setStatus(EnumJobStatus.RUNNING.getCode());
this.updateById(update);
}
} catch (SchedulerException e) {
log.error("启动任务失败id={}", id, e);
throw new RuntimeException(e);
}
return null;
});
projectCurrentStateOrStop(id);
return null;
});
}
@Override
public void stopJob(BigInteger id) {
redisLockExecutor.executeWithLock(JOB_LOCK_KEY_PREFIX + id, LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIMEOUT, () -> {
SysJob sysJob = this.getById(id);
if (sysJob == null) {
throw new IllegalStateException("任务不存在id=" + id);
}
withJobCommandLock(id, () -> {
inManagementTransaction(() -> {
SysJob job = requireJobForUpdate(id);
if (!Integer.valueOf(EnumJobStatus.STOP.getCode()).equals(job.getStatus())) {
SysJob stopped = new SysJob();
stopped.setId(id);
stopped.setStatus(EnumJobStatus.STOP.getCode());
if (!updateById(stopped)) {
throw new IllegalStateException("定时任务停止失败id=" + id);
}
}
return null;
});
try {
JobKey jobKey = JobUtil.getJobKey(sysJob);
if (scheduler.checkExists(jobKey)) {
deleteJob(Collections.singletonList(id));
}
if (!Integer.valueOf(EnumJobStatus.STOP.getCode()).equals(sysJob.getStatus())) {
SysJob update = new SysJob();
update.setId(id);
update.setStatus(EnumJobStatus.STOP.getCode());
this.updateById(update);
}
} catch (SchedulerException e) {
log.error("停止任务失败id={}", id, e);
throw new RuntimeException(e);
projectCurrentStateOrStop(id);
} finally {
cancelPendingBestEffort(id, "任务已停止");
}
return null;
});
}
/**
* {@inheritDoc}
*/
@Override
public String triggerNow(BigInteger id) {
return withJobCommandLock(id, () -> inManagementTransaction(() -> {
SysJob job = requireJobForUpdate(id);
if (!Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(job.getStatus())) {
throw new BusinessException(409, 409, "任务未处于运行状态,请先启动任务");
}
return scheduleAdapter.triggerNow(id);
}));
}
@Override
public List<Date> nextFireTimes(String cronExpression, int limit) {
return scheduleService.nextFireTimes(new CronSchedulePlan(cronExpression, zoneId), limit)
.stream().map(Date::from).toList();
}
@Override
public List<SysJob> listWorkflowJobsByWorkflowId(BigInteger workflowId) {
if (workflowId == null) {
return List.of();
}
if (workflowId == null) return List.of();
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(SysJob::getJobType, EnumJobType.TINY_FLOW.getCode());
.eq(SysJob::getJobType, EnumJobType.TINY_FLOW.getCode());
return list(queryWrapper).stream()
.filter(job -> workflowId.equals(SysJobWorkflowReferenceSupport.resolveWorkflowId(job)))
.toList();
.filter(job -> workflowId.equals(SysJobWorkflowReferenceSupport.resolveWorkflowId(job)))
.toList();
}
private SysJob requireJob(BigInteger id) {
SysJob job = getById(id);
if (job == null) throw new IllegalStateException("任务不存在id=" + id);
return job;
}
private boolean projectCurrentStateOrStop(BigInteger jobId) {
// 第二段持任务行锁完成 JobStoreTX 投影。Provider 失败必须在同一行锁下把
// 业务状态改为 STOP事务基础设施或 commit 失败则直接抛出,不做破坏性清理。
ProjectionOutcome outcome = inManagementTransaction(() -> {
SysJob current = getMapper().selectByIdForUpdate(jobId);
boolean running = current != null
&& Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(current.getStatus());
try {
if (running) {
scheduleAdapter.replace(current);
} else {
scheduleAdapter.delete(jobId);
}
return new ProjectionOutcome(running, null);
} catch (RuntimeException providerFailure) {
if (current != null
&& !Integer.valueOf(EnumJobStatus.STOP.getCode())
.equals(current.getStatus())) {
SysJob stopped = new SysJob();
stopped.setId(jobId);
stopped.setStatus(EnumJobStatus.STOP.getCode());
if (!updateById(stopped)) {
throw new IllegalStateException(
"调度投影失败后无法停止任务id=" + jobId,
providerFailure);
}
}
return new ProjectionOutcome(false, providerFailure);
}
});
if (outcome.failure() == null) return outcome.running();
RuntimeException providerFailure = outcome.failure();
try {
// STOP 已确认提交后再重试删除投影,并再次锁行检查;若更新命令已重新
// 启动任务则跳过旧清理,避免失锁窗口中的 stale delete。
inManagementTransaction(() -> {
SysJob current = getMapper().selectByIdForUpdate(jobId);
if (current == null
|| Integer.valueOf(EnumJobStatus.STOP.getCode())
.equals(current.getStatus())) {
scheduleAdapter.delete(jobId);
}
return null;
});
} catch (RuntimeException cleanupFailure) {
if (cleanupFailure != providerFailure) {
providerFailure.addSuppressed(cleanupFailure);
}
}
cancelPendingBestEffort(jobId, "调度投影同步失败,任务已停止");
throw providerFailure;
}
private SysJob requireJobForUpdate(BigInteger id) {
SysJob job = getMapper().selectByIdForUpdate(id);
if (job == null) throw new IllegalStateException("任务不存在id=" + id);
return job;
}
private <T> T withJobCommandLock(BigInteger id, Supplier<T> command) {
return withManagementPermit(() -> redisLockExecutor.executeWithRenewingLock(
lockKey(id), LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIMEOUT, command));
}
private <T> T inManagementTransaction(Supplier<T> command) {
return managementTransaction.execute(status -> command.get());
}
private <T> T withManagementPermit(Supplier<T> command) {
boolean acquired = false;
try {
acquired = managementPermits.tryAcquire(
LOCK_WAIT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
if (!acquired) {
throw new IllegalStateException("定时任务管理命令繁忙,请稍后重试");
}
return command.get();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("等待定时任务管理命令资源时被中断", exception);
} finally {
if (acquired) managementPermits.release();
}
}
private void cancelPendingBestEffort(BigInteger jobId, String reason) {
try {
// 与 Worker 一致按日志行 -> 任务行加锁,并在管理状态提交后复核最终状态。
executionStore.cancelPending(jobId, reason);
} catch (RuntimeException cleanupFailure) {
// Worker 领取遗留记录时仍会根据 STOP/已删除状态安全取消。
log.warn("定时任务状态已收口,但待执行记录延迟清理: jobId={}",
jobId, cleanupFailure);
}
}
private static String lockKey(BigInteger id) {
if (id == null) throw new IllegalArgumentException("任务ID不能为空");
return JOB_LOCK_KEY_PREFIX + id;
}
private static long nextScheduleGeneration(SysJob job) {
Long generation = job.getScheduleGeneration();
if (generation == null || generation < 0L || generation == Long.MAX_VALUE) {
throw new IllegalStateException(
"定时任务调度代际非法id=" + job.getId() + ", generation=" + generation);
}
return generation + 1L;
}
private record ProjectionOutcome(boolean running, RuntimeException failure) {
}
}

View File

@@ -1,146 +1,18 @@
package tech.easyflow.job.util;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import org.quartz.JobKey;
import org.quartz.TriggerKey;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.service.WorkflowJobExecutionService;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
/** Java 类定时任务的开发环境示例。 */
public class JobUtil {
/**
* sysJobService.test()
*/
public static Object execSpringBean(SysJob job) {
Map<String, Object> jobParams = job.getJobParams();
if (jobParams != null) {
String beanMethod = jobParams.get(JobConstant.BEAN_METHOD_KEY).toString();
String[] strings = StrUtil.subBefore(beanMethod, "(", false).split("\\.");
Object bean = SpringContextUtil.getBean(strings[0]);
String param = StrUtil.subBetween(beanMethod, "(", ")");
try {
// 调用方法并传递参数
return invoke(bean, strings[1], getParams(param));
} catch (Exception e) {
throw new RuntimeException("执行 beanMethod 报错:", e);
}
}
return null;
public String execTest(String a, Integer b, Double c, Long d) {
return "a=" + a + ",b=" + b + ",c=" + c + ",d=" + d;
}
/**
* tech.easyflow.job.util.JobUtil.execTest("test",1,0.52D,100L)
* @param job
*/
public static Object execJavaClass(SysJob job) {
Map<String, Object> jobParams = job.getJobParams();
if (jobParams != null) {
try {
String javaMethod = jobParams.get(JobConstant.JAVA_METHOD_KEY).toString();
String before = StrUtil.subBefore(javaMethod, "(", false);
String[] strings = before.split("\\.");
String className = String.join(".", Arrays.copyOf(strings, strings.length - 1));
String methodName = strings[strings.length - 1];
String param = StrUtil.subBetween(javaMethod, "(", ")");
Object obj = Class.forName(className).getDeclaredConstructor().newInstance();;
return invoke(obj, methodName, getParams(param));
} catch (Exception e) {
throw new RuntimeException("执行 javaMethod 报错: ",e);
}
/** 可用于开发环境容量与并发验收的阻塞型模拟任务。 */
public String sleepTask(String label, Long millis) throws InterruptedException {
if (millis == null || millis < 0 || millis > 300_000) {
throw new IllegalArgumentException("millis must be between 0 and 300000");
}
return null;
}
/**
* 通过任务模块的受控执行服务运行工作流。
*
* @param job Quartz 中保存的任务快照
* @return 工作流执行结果
*/
public static Object execWorkFlow(SysJob job) {
WorkflowJobExecutionService executionService =
SpringContextUtil.getBean(WorkflowJobExecutionService.class);
return executionService.execute(job);
}
public static Object execute(SysJob job) {
Object res = null;
Integer jobType = job.getJobType();
if (EnumJobType.TINY_FLOW.getCode() == jobType) {
res = execWorkFlow(job);
}
if (EnumJobType.SPRING_BEAN.getCode() == jobType) {
res = execSpringBean(job);
}
if (EnumJobType.JAVA_CLASS.getCode() == jobType) {
res = execJavaClass(job);
}
return res;
}
public void execTest(String a,Integer b,Double c,Long d) {
System.out.println("动态执行方法,执行参数:" + "a="+ a + ",b="+ b + ",c="+ c + ",d="+ d);
}
private static Object[] getParams(String param) {
if (StrUtil.isEmpty(param)) {
return new Object[]{new Class<?>[]{}, new Object[]{}};
}
String[] splits = param.split(",");
Object[] res = new Object[2];
Object[] params = new Object[splits.length];
Class<?>[] paramTypes = new Class[splits.length];
for (int i = 0; i < splits.length; i++) {
String split = splits[i].trim();
if (split.startsWith("\"")) {
params[i] = split.substring(1, split.length() - 1);
paramTypes[i] = String.class;
} else if ("true".equals(split) || "false".equals(split)) {
params[i] = Boolean.valueOf(split);
paramTypes[i] = Boolean.class;
} else if (split.endsWith("L")) {
params[i] = Long.valueOf(split.substring(0, split.length() - 1));
paramTypes[i] = Long.class;
} else if (split.endsWith("D")) {
params[i] = Double.valueOf(split.substring(0, split.length() - 1));
paramTypes[i] = Double.class;
} else if (split.endsWith("F")) {
params[i] = Float.valueOf(split.substring(0, split.length() - 1));
paramTypes[i] = Float.class;
} else {
params[i] = Integer.valueOf(split);
paramTypes[i] = Integer.class;
}
}
res[0] = paramTypes;
res[1] = params;
return res;
}
private static Object invoke(Object bean, String methodName, Object[] params) throws Exception {
Object[] args = (Object[]) params[1];
if (ArrayUtil.isEmpty(params[1])) {
Method method = bean.getClass().getDeclaredMethod(methodName);
return method.invoke(bean);
} else {
Method method = bean.getClass().getDeclaredMethod(methodName, (Class<?>[]) params[0]);
return method.invoke(bean, args);
}
}
public static JobKey getJobKey(SysJob job) {
return JobKey.jobKey(job.getId().toString(), JobConstant.JOB_GROUP);
}
public static TriggerKey getTriggerKey(SysJob job) {
return TriggerKey.triggerKey(job.getId().toString(), JobConstant.JOB_GROUP);
Thread.sleep(millis);
return label;
}
}

View File

@@ -0,0 +1,23 @@
package tech.easyflow.job.config;
import org.junit.Assert;
import org.junit.Test;
public class SysJobConnectionCapacityValidatorTest {
@Test
public void configuredBaselineMustReserveAllSchedulerConnections() {
Assert.assertEquals(24,
SysJobConnectionCapacityValidator.requiredPoolSize(4, 8, 4, 4));
SysJobExecutionProperties properties = new SysJobExecutionProperties();
new SysJobConnectionCapacityValidator(properties, 24, 8, true)
.afterPropertiesSet();
}
@Test(expected = IllegalStateException.class)
public void insufficientPoolMustFailFast() {
SysJobExecutionProperties properties = new SysJobExecutionProperties();
new SysJobConnectionCapacityValidator(properties, 23, 8, true)
.afterPropertiesSet();
}
}

View File

@@ -0,0 +1,148 @@
package tech.easyflow.job.execution;
import com.easyagents.scheduler.ScheduleFireContext;
import com.easyagents.scheduler.ScheduleId;
import com.easyagents.scheduler.ScheduleRefireException;
import org.junit.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.TransientDataAccessResourceException;
import org.springframework.transaction.CannotCreateTransactionException;
import org.springframework.transaction.TransactionSystemException;
import tech.easyflow.job.config.SysJobExecutionProperties;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
public class EasyFlowScheduleHandlerTest {
@Test
public void shouldRetryOnlyBoundedTransientRegistrationFailures() {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(new TransientDataAccessResourceException("temporary"))
.doThrow(new TransientDataAccessResourceException("temporary"))
.doNothing().when(registrar).register(context);
SysJobExecutionProperties properties = new SysJobExecutionProperties();
properties.setRegistrationMaxAttempts(3);
properties.setRegistrationRetryDelay(Duration.ofNanos(1));
new EasyFlowScheduleHandler(registrar, properties,
mock(SysJobExecutionMetrics.class)).execute(context);
verify(registrar, times(3)).register(context);
}
@Test(expected = IllegalStateException.class)
public void shouldNotRetryBusinessFailure() {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(new IllegalStateException("invalid job")).when(registrar).register(context);
new EasyFlowScheduleHandler(registrar, new SysJobExecutionProperties(),
mock(SysJobExecutionMetrics.class)).execute(context);
}
@Test(expected = ScheduleRefireException.class)
public void shouldRequestQuartzRefireAfterTransientRetriesAreExhausted() {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(new TransientDataAccessResourceException("temporary"))
.when(registrar).register(context);
SysJobExecutionProperties properties = new SysJobExecutionProperties();
properties.setRegistrationMaxAttempts(2);
properties.setRegistrationRetryDelay(Duration.ofNanos(1));
new EasyFlowScheduleHandler(registrar, properties,
mock(SysJobExecutionMetrics.class)).execute(context);
}
@Test(expected = ScheduleRefireException.class)
public void shouldRequestQuartzRefireWhenDatabaseConnectionIsUnavailable() {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(new DataAccessResourceFailureException("connection unavailable"))
.when(registrar).register(context);
SysJobExecutionProperties properties = new SysJobExecutionProperties();
properties.setRegistrationMaxAttempts(2);
properties.setRegistrationRetryDelay(Duration.ofNanos(1));
try {
new EasyFlowScheduleHandler(registrar, properties,
mock(SysJobExecutionMetrics.class)).execute(context);
} finally {
verify(registrar, times(2)).register(context);
}
}
@Test(expected = ScheduleRefireException.class)
public void transactionBeginFailureMustRequestQuartzRefire() {
assertTransactionFailureRequestsRefire(
new CannotCreateTransactionException("pool exhausted"));
}
@Test(expected = ScheduleRefireException.class)
public void transactionCommitFailureMustRequestQuartzRefire() {
assertTransactionFailureRequestsRefire(
new TransactionSystemException("commit failed"));
}
@Test(expected = ScheduleRefireException.class)
public void interruptedLocalRetryMustStillRequestDurableQuartzRefire() {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(new TransientDataAccessResourceException("temporary"))
.when(registrar).register(context);
SysJobExecutionProperties properties = new SysJobExecutionProperties();
properties.setRegistrationMaxAttempts(3);
properties.setRegistrationRetryDelay(Duration.ofSeconds(1));
Thread.currentThread().interrupt();
try {
new EasyFlowScheduleHandler(registrar, properties,
mock(SysJobExecutionMetrics.class)).execute(context);
} finally {
Thread.interrupted();
}
}
@Test(expected = DataIntegrityViolationException.class)
public void shouldNotRetryNonRecoverableDataError() {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(new DataIntegrityViolationException("invalid row"))
.when(registrar).register(context);
try {
new EasyFlowScheduleHandler(registrar, new SysJobExecutionProperties(),
mock(SysJobExecutionMetrics.class)).execute(context);
} finally {
verify(registrar, times(1)).register(context);
}
}
private static void assertTransactionFailureRequestsRefire(RuntimeException failure) {
SysJobExecutionRegistrar registrar = mock(SysJobExecutionRegistrar.class);
ScheduleFireContext context = context();
doThrow(failure).when(registrar).register(context);
SysJobExecutionProperties properties = new SysJobExecutionProperties();
properties.setRegistrationMaxAttempts(2);
properties.setRegistrationRetryDelay(Duration.ofNanos(1));
new EasyFlowScheduleHandler(registrar, properties,
mock(SysJobExecutionMetrics.class)).execute(context);
}
private static ScheduleFireContext context() {
Instant now = Instant.now();
return new ScheduleFireContext(new ScheduleId("easyflow.job", "1"),
EasyFlowScheduleHandler.CODE, now, now, "fire-1", null, false, Map.of());
}
}

View File

@@ -0,0 +1,83 @@
package tech.easyflow.job.execution;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.job.mapper.SysJobLogMapper;
import java.lang.reflect.Method;
import java.util.Locale;
public class SysJobExecutionMapperContractTest {
@Test
public void claimShouldUseSkipLockedAndOwnedTerminalCas() throws Exception {
String expiredSql = sql(SysJobLogMapper.class.getMethod(
"selectExpiredClaimCandidateId").getAnnotation(Select.class).value());
String expiredProbeSql = sql(SysJobLogMapper.class.getMethod(
"selectExpiredClaimCandidateIdWithoutLock").getAnnotation(Select.class).value());
String pendingSql = sql(SysJobLogMapper.class.getMethod(
"selectPendingClaimCandidateId").getAnnotation(Select.class).value());
String finishSql = sql(SysJobLogMapper.class.getMethod("finishOwned",
java.math.BigInteger.class, String.class, String.class, int.class,
String.class, String.class).getAnnotation(Update.class).value());
Assert.assertTrue(expiredSql.contains("FOR UPDATE SKIP LOCKED"));
Assert.assertFalse(expiredProbeSql.contains("FOR UPDATE"));
Assert.assertTrue(expiredProbeSql.contains("STATUS=3"));
Assert.assertTrue(pendingSql.contains("FOR UPDATE SKIP LOCKED"));
Assert.assertTrue(expiredSql.startsWith("SELECT ID "));
Assert.assertTrue(pendingSql.startsWith("SELECT ID "));
Assert.assertFalse(expiredSql.contains("SELECT *"));
Assert.assertFalse(pendingSql.contains("SELECT *"));
Assert.assertFalse(expiredSql.contains(" OR "));
Assert.assertFalse(pendingSql.contains(" OR "));
Assert.assertTrue(finishSql.contains("STATUS=3"));
Assert.assertTrue(finishSql.contains("LEASE_OWNER=#{OWNER}"));
Assert.assertTrue(finishSql.contains("EXECUTION_TOKEN=#{TOKEN}"));
}
@Test
public void heartbeatMustNotAdvanceFencingVersion() throws Exception {
Method method = SysJobLogMapper.class.getMethod("renewLease",
java.math.BigInteger.class, String.class, String.class,
long.class);
String heartbeatSql = sql(method.getAnnotation(Update.class).value());
Assert.assertFalse(heartbeatSql.contains("VERSION"));
Assert.assertTrue(heartbeatSql.contains("LEASE_OWNER=#{OWNER}"));
Assert.assertTrue(heartbeatSql.contains("EXECUTION_TOKEN=#{TOKEN}"));
Assert.assertTrue(heartbeatSql.contains("CURRENT_TIMESTAMP(3)"));
}
@Test
public void exhaustedPendingInfrastructureRetryMustLeaveClaimQueue() throws Exception {
String sql = sql(SysJobLogMapper.class.getMethod("markPendingDead",
java.math.BigInteger.class, String.class).getAnnotation(Update.class).value());
Assert.assertTrue(sql.contains("STATUS=4"));
Assert.assertTrue(sql.contains("WHERE ID=#{ID} AND STATUS=2"));
Assert.assertTrue(sql.contains("NEXT_RETRY_TIME=NULL"));
}
@Test
public void cancellationUpdateMustOnlyTouchPendingLedgerRows() throws Exception {
String lockSql = sql(SysJobLogMapper.class.getMethod(
"selectPendingIdsByJobIdForUpdate", java.math.BigInteger.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
String sql = sql(SysJobLogMapper.class.getMethod("cancelPendingByJobId",
java.math.BigInteger.class, String.class)
.getAnnotation(Update.class).value());
Assert.assertTrue(lockSql.contains("WHERE JOB_ID=#{JOBID} AND STATUS=2"));
Assert.assertTrue(lockSql.contains("ORDER BY ID FOR UPDATE"));
Assert.assertTrue(sql.contains("WHERE JOB_ID=#{JOBID} AND STATUS=2"));
Assert.assertFalse(sql.contains("FROM TB_SYS_JOB"));
}
private static String sql(String[] fragments) {
return String.join(" ", fragments).replaceAll("\\s+", " ")
.toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.job.execution;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.Assert;
import org.junit.Test;
public class SysJobExecutionMetricsTest {
@Test
public void completionWithoutOwnedTerminalStatusMustBeAccepted() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
SysJobExecutionMetrics metrics = new SysJobExecutionMetrics(registry);
metrics.executionStarted();
metrics.executionFinished(null, 1L);
Assert.assertEquals(0.0D,
registry.get("easyflow.job.execution.local_active").gauge().value(), 0.0D);
Assert.assertEquals(1L,
registry.get("easyflow.job.execution.duration").timer().count());
Assert.assertEquals(0.0D,
registry.get("easyflow.job.execution.terminal")
.tag("status", "success").counter().count(), 0.0D);
Assert.assertEquals(0.0D,
registry.get("easyflow.job.execution.terminal")
.tag("status", "failure").counter().count(), 0.0D);
}
}

View File

@@ -0,0 +1,210 @@
package tech.easyflow.job.execution;
import com.easyagents.scheduler.ScheduleFireContext;
import com.easyagents.scheduler.ScheduleId;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.mapper.SysJobLogMapper;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.job.JobConstant;
import java.math.BigInteger;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class SysJobExecutionStoreTest {
@Test
public void executionKeyDuplicateMustBeAcceptedAsIdempotentRegistration() {
RegistrationFixture fixture = registrationFixture();
doThrow(new DuplicateKeyException("execution key duplicate"))
.when(fixture.logMapper()).insert(any(SysJobLog.class));
when(fixture.logMapper().selectIdByExecutionKey(anyString()))
.thenReturn(BigInteger.valueOf(999L));
fixture.store().register(fixture.context());
verify(fixture.logMapper()).selectIdByExecutionKey(anyString());
}
@Test
public void unrelatedUniqueConstraintViolationMustNotLoseFire() {
RegistrationFixture fixture = registrationFixture();
DuplicateKeyException duplicate = new DuplicateKeyException("primary key duplicate");
doThrow(duplicate).when(fixture.logMapper()).insert(any(SysJobLog.class));
when(fixture.logMapper().selectIdByExecutionKey(anyString())).thenReturn(null);
DuplicateKeyException thrown = Assert.assertThrows(
DuplicateKeyException.class,
() -> fixture.store().register(fixture.context()));
Assert.assertSame(duplicate, thrown);
verify(fixture.logMapper()).selectIdByExecutionKey(anyString());
}
@Test
public void exhaustedHeadRowsMustNotDelayFollowingRunnableExecution() {
SysJobMapper jobMapper = mock(SysJobMapper.class);
SysJobLogMapper logMapper = mock(SysJobLogMapper.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class));
SysJobExecutionProperties properties = new SysJobExecutionProperties();
SysJobLog exhausted1 = execution(101L, 1_001L,
properties.getInfrastructureRetryLimit());
SysJobLog exhausted2 = execution(102L, 1_002L,
properties.getInfrastructureRetryLimit());
SysJobLog runnable = execution(103L, 1_003L, 0);
when(logMapper.selectExpiredClaimCandidate()).thenReturn(null);
when(logMapper.selectPendingClaimCandidate())
.thenReturn(exhausted1, exhausted2, runnable);
when(logMapper.markPendingDead(any(), anyString())).thenReturn(1);
when(logMapper.claimPending(eq(runnable.getId()), eq("node-a"),
anyString(), anyLong())).thenReturn(1);
SysJob activeJob = new SysJob();
activeJob.setId(runnable.getJobId());
activeJob.setStatus(EnumJobStatus.RUNNING.getCode());
when(jobMapper.selectByIdForUpdate(runnable.getJobId())).thenReturn(activeJob);
SysJobExecutionStore store = new SysJobExecutionStore(
jobMapper, logMapper, properties, metrics, transactionManager);
Optional<ClaimedSysJobExecution> claimed = store.claimOne("node-a");
Assert.assertTrue(claimed.isPresent());
Assert.assertEquals(runnable.getId(), claimed.get().execution().getId());
verify(logMapper, times(2)).markPendingDead(any(), anyString());
verify(metrics, times(2)).recordDead();
verify(transactionManager, times(3)).commit(any());
}
@Test
public void transientClaimDeadlockMustRetryInANewTransaction() {
SysJobMapper jobMapper = mock(SysJobMapper.class);
SysJobLogMapper logMapper = mock(SysJobLogMapper.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class));
SysJobExecutionProperties properties = new SysJobExecutionProperties();
SysJobLog runnable = execution(201L, 2_001L, 0);
when(logMapper.selectExpiredClaimCandidate()).thenReturn(null);
when(logMapper.selectPendingClaimCandidate())
.thenThrow(new CannotAcquireLockException(
"simulated deadlock", new SQLException("deadlock")))
.thenReturn(runnable);
SysJob activeJob = new SysJob();
activeJob.setId(runnable.getJobId());
activeJob.setStatus(EnumJobStatus.RUNNING.getCode());
when(jobMapper.selectByIdForUpdate(runnable.getJobId())).thenReturn(activeJob);
when(logMapper.claimPending(eq(runnable.getId()), eq("node-a"),
anyString(), anyLong())).thenReturn(1);
SysJobExecutionStore store = new SysJobExecutionStore(
jobMapper, logMapper, properties, metrics, transactionManager);
Optional<ClaimedSysJobExecution> claimed = store.claimOne("node-a");
Assert.assertTrue(claimed.isPresent());
Assert.assertEquals(runnable.getId(), claimed.get().execution().getId());
verify(transactionManager, times(2)).getTransaction(any());
verify(transactionManager, times(1)).rollback(any());
verify(transactionManager, times(1)).commit(any());
verify(logMapper, never()).markPendingDead(any(), anyString());
}
@Test
public void lockedExpiredCandidateMustFallBackToPendingExecution() {
SysJobMapper jobMapper = mock(SysJobMapper.class);
SysJobLogMapper logMapper = mock(SysJobLogMapper.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class));
SysJobExecutionProperties properties = new SysJobExecutionProperties();
SysJobLog runnable = execution(301L, 3_001L, 0);
when(logMapper.selectExpiredClaimCandidateIdWithoutLock())
.thenReturn(BigInteger.valueOf(300L));
// 过期候选已被其他 Worker 锁定SKIP LOCKED 后必须继续领取 PENDING。
when(logMapper.selectExpiredClaimCandidate()).thenReturn(null);
when(logMapper.selectPendingClaimCandidate()).thenReturn(runnable);
SysJob activeJob = new SysJob();
activeJob.setId(runnable.getJobId());
activeJob.setStatus(EnumJobStatus.RUNNING.getCode());
when(jobMapper.selectByIdForUpdate(runnable.getJobId())).thenReturn(activeJob);
when(logMapper.claimPending(eq(runnable.getId()), eq("node-a"),
anyString(), anyLong())).thenReturn(1);
SysJobExecutionStore store = new SysJobExecutionStore(
jobMapper, logMapper, properties, metrics, transactionManager);
Optional<ClaimedSysJobExecution> claimed = store.claimOne("node-a");
Assert.assertTrue(claimed.isPresent());
Assert.assertEquals(runnable.getId(), claimed.get().execution().getId());
verify(logMapper).selectExpiredClaimCandidate();
verify(logMapper).selectPendingClaimCandidate();
verify(transactionManager, times(2)).commit(any());
}
private static SysJobLog execution(long id, long jobId, int attempts) {
SysJobLog execution = new SysJobLog();
execution.setId(BigInteger.valueOf(id));
execution.setJobId(BigInteger.valueOf(jobId));
execution.setAttemptCount(attempts);
return execution;
}
private static RegistrationFixture registrationFixture() {
SysJobMapper jobMapper = mock(SysJobMapper.class);
SysJobLogMapper logMapper = mock(SysJobLogMapper.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class));
SysJob job = new SysJob();
job.setId(BigInteger.ONE);
job.setTenantId(BigInteger.TWO);
job.setStatus(EnumJobStatus.RUNNING.getCode());
job.setScheduleGeneration(0L);
when(jobMapper.selectByIdForUpdate(BigInteger.ONE)).thenReturn(job);
Instant now = Instant.now();
ScheduleFireContext context = new ScheduleFireContext(
new ScheduleId("easyflow.job", "1"),
EasyFlowScheduleHandler.CODE,
now,
now,
"fire-1",
null,
false,
Map.of(JobConstant.SCHEDULE_GENERATION, "0"));
return new RegistrationFixture(
new SysJobExecutionStore(jobMapper, logMapper,
new SysJobExecutionProperties(), metrics, transactionManager),
logMapper,
context);
}
private record RegistrationFixture(SysJobExecutionStore store,
SysJobLogMapper logMapper,
ScheduleFireContext context) {
}
}

View File

@@ -0,0 +1,256 @@
package tech.easyflow.job.execution;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJobLog;
import java.math.BigInteger;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class SysJobExecutionWorkerTest {
@Test
public void nonVmErrorMustNotPermanentlyReduceWorkerCapacity() throws Exception {
SysJobExecutionStore store = mock(SysJobExecutionStore.class);
SysJobInvoker invoker = mock(SysJobInvoker.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
SysJobExecutionProperties properties = properties();
ClaimedSysJobExecution first = claim(101L, "token-1");
ClaimedSysJobExecution second = claim(102L, "token-2");
when(store.queueSnapshot()).thenReturn(new SysJobQueueSnapshot(0, 0, 0));
when(store.claimOne(any())).thenReturn(
Optional.of(first), Optional.of(second), Optional.empty());
when(store.finish(any(), anyInt(), nullable(String.class), nullable(String.class)))
.thenReturn(true);
CountDownLatch secondCompleted = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
doAnswer(invocation -> {
if (calls.incrementAndGet() == 1) {
throw new AssertionError("broken business handler");
}
secondCompleted.countDown();
return "ok";
}).when(invoker).execute(any());
SysJobExecutionWorker worker = new SysJobExecutionWorker(
store, invoker, properties, metrics);
worker.start();
try {
Assert.assertTrue("worker 未在非 VM Error 后继续领取任务",
secondCompleted.await(3, TimeUnit.SECONDS));
verify(store, timeout(1_000)).finish(
first, 0, null, "AssertionError: broken business handler");
verify(store, timeout(1_000)).finish(second, 1, "\"ok\"", null);
} finally {
worker.stop();
}
}
@Test
public void preparationInfrastructureFailureMustReleaseForRetry() throws Exception {
SysJobExecutionStore store = mock(SysJobExecutionStore.class);
SysJobInvoker invoker = mock(SysJobInvoker.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
ClaimedSysJobExecution claim = claim(201L, "token-infra");
when(store.queueSnapshot()).thenReturn(new SysJobQueueSnapshot(0, 0, 0));
when(store.claimOne(any())).thenReturn(Optional.of(claim), Optional.empty());
CountDownLatch released = new CountDownLatch(1);
when(store.releaseForInfrastructureRetry(any(), any())).thenAnswer(invocation -> {
released.countDown();
return true;
});
doAnswer(invocation -> {
throw new SysJobInfrastructureException("database unavailable",
new IllegalStateException("connection lost"));
}).when(invoker).execute(any());
SysJobExecutionWorker worker = new SysJobExecutionWorker(
store, invoker, properties(), metrics);
worker.start();
try {
Assert.assertTrue(released.await(3, TimeUnit.SECONDS));
verify(store).releaseForInfrastructureRetry(
claim, "IllegalStateException: connection lost");
} finally {
worker.stop();
}
}
@Test
public void metricsFailureMustNotLeakContextOrActiveExecution() throws Exception {
SysJobExecutionStore store = mock(SysJobExecutionStore.class);
SysJobInvoker invoker = mock(SysJobInvoker.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
ClaimedSysJobExecution first = claim(251L, "token-metrics-1");
ClaimedSysJobExecution second = claim(252L, "token-metrics-2");
when(store.queueSnapshot()).thenReturn(new SysJobQueueSnapshot(0, 0, 0));
when(store.claimOne(any())).thenReturn(
Optional.of(first), Optional.of(second), Optional.empty());
when(store.finish(any(), anyInt(), nullable(String.class), nullable(String.class)))
.thenReturn(true);
doThrow(new IllegalStateException("meter unavailable"))
.doNothing()
.when(metrics).executionFinished(nullable(String.class), anyLong());
CountDownLatch secondCompleted = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
doAnswer(invocation -> {
if (calls.incrementAndGet() == 2) secondCompleted.countDown();
return "ok";
}).when(invoker).execute(any());
SysJobExecutionWorker worker = new SysJobExecutionWorker(
store, invoker, properties(), metrics);
worker.start();
try {
Assert.assertTrue("指标收尾异常后 Worker 未继续执行下一任务",
secondCompleted.await(3, TimeUnit.SECONDS));
verify(store, timeout(1_000)).finish(first, 1, "\"ok\"", null);
verify(store, timeout(1_000)).finish(second, 1, "\"ok\"", null);
} finally {
worker.stop();
}
}
@Test
public void fencingRejectionMustNotPolluteNextExecution() throws Exception {
SysJobExecutionStore store = mock(SysJobExecutionStore.class);
SysJobInvoker invoker = mock(SysJobInvoker.class);
SysJobExecutionMetrics metrics = new SysJobExecutionMetrics(
new SimpleMeterRegistry());
ClaimedSysJobExecution first = claim(271L, "token-fenced");
ClaimedSysJobExecution second = claim(272L, "token-next");
when(store.queueSnapshot()).thenReturn(new SysJobQueueSnapshot(0, 0, 0));
when(store.claimOne(any())).thenReturn(
Optional.of(first), Optional.of(second), Optional.empty());
when(store.finish(any(), anyInt(), nullable(String.class), nullable(String.class)))
.thenReturn(false, true);
CountDownLatch secondCompleted = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
doAnswer(invocation -> {
if (calls.incrementAndGet() == 2) secondCompleted.countDown();
return "ok";
}).when(invoker).execute(any());
SysJobExecutionWorker worker = new SysJobExecutionWorker(
store, invoker, properties(), metrics);
worker.start();
try {
Assert.assertTrue("fencing 拒绝后 Worker 未继续执行下一任务",
secondCompleted.await(3, TimeUnit.SECONDS));
verify(store, timeout(1_000)).finish(first, 1, "\"ok\"", null);
verify(store, timeout(1_000)).finish(second, 1, "\"ok\"", null);
} finally {
worker.stop();
}
}
@Test
public void staleHeartbeatMustNotInterruptNextExecutionOnReusedWorkerThread()
throws Exception {
SysJobExecutionStore store = mock(SysJobExecutionStore.class);
SysJobInvoker invoker = mock(SysJobInvoker.class);
SysJobExecutionMetrics metrics = mock(SysJobExecutionMetrics.class);
SysJobExecutionProperties properties = properties();
properties.setHeartbeatInterval(Duration.ofMillis(20));
ClaimedSysJobExecution first = claim(301L, "token-stale");
ClaimedSysJobExecution second = claim(302L, "token-current");
when(store.queueSnapshot()).thenReturn(new SysJobQueueSnapshot(0, 0, 0));
when(store.claimOne(any())).thenReturn(
Optional.of(first), Optional.of(second), Optional.empty());
when(store.finish(any(), anyInt(), nullable(String.class), nullable(String.class)))
.thenReturn(true);
when(store.heartbeat(eq(second))).thenReturn(true);
CountDownLatch firstStarted = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
CountDownLatch staleHeartbeatStarted = new CountDownLatch(1);
CountDownLatch lostLeaseMetricStarted = new CountDownLatch(1);
CountDownLatch releaseLostLeaseMetric = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
CountDownLatch releaseSecond = new CountDownLatch(1);
when(store.heartbeat(eq(first))).thenAnswer(invocation -> {
staleHeartbeatStarted.countDown();
return false;
});
doAnswer(invocation -> {
lostLeaseMetricStarted.countDown();
releaseLostLeaseMetric.await(3, TimeUnit.SECONDS);
return null;
}).when(metrics).recordLostLease();
AtomicInteger calls = new AtomicInteger();
doAnswer(invocation -> {
if (calls.incrementAndGet() == 1) {
firstStarted.countDown();
releaseFirst.await(3, TimeUnit.SECONDS);
return "first";
}
secondStarted.countDown();
releaseSecond.await(3, TimeUnit.SECONDS);
return "second";
}).when(invoker).execute(any());
SysJobExecutionWorker worker = new SysJobExecutionWorker(
store, invoker, properties, metrics);
worker.start();
try {
Assert.assertTrue(firstStarted.await(1, TimeUnit.SECONDS));
Assert.assertTrue(staleHeartbeatStarted.await(1, TimeUnit.SECONDS));
Assert.assertTrue(lostLeaseMetricStarted.await(1, TimeUnit.SECONDS));
releaseFirst.countDown();
Assert.assertTrue(secondStarted.await(1, TimeUnit.SECONDS));
releaseLostLeaseMetric.countDown();
Thread.sleep(100L);
releaseSecond.countDown();
verify(store, timeout(1_000)).finish(second, 1, "\"second\"", null);
verify(store, never()).finish(eq(second), eq(0),
nullable(String.class), nullable(String.class));
verify(metrics).recordLostLease();
} finally {
releaseFirst.countDown();
releaseLostLeaseMetric.countDown();
releaseSecond.countDown();
worker.stop();
}
}
private static SysJobExecutionProperties properties() {
SysJobExecutionProperties properties = new SysJobExecutionProperties();
properties.setWorkerCount(1);
properties.setPollInterval(Duration.ofMillis(10));
properties.setLeaseDuration(Duration.ofSeconds(3));
properties.setHeartbeatInterval(Duration.ofMillis(500));
properties.setShutdownWaitTimeout(Duration.ofSeconds(1));
return properties;
}
private static ClaimedSysJobExecution claim(long id, String token) {
SysJobLog execution = new SysJobLog();
execution.setId(BigInteger.valueOf(id));
execution.setJobId(BigInteger.valueOf(id + 1_000L));
execution.setTenantId(BigInteger.ONE);
execution.setDeptId(BigInteger.ONE);
execution.setJobType(3);
execution.setAttemptCount(1);
return new ClaimedSysJobExecution(execution, "node-a", token);
}
}

View File

@@ -0,0 +1,140 @@
package tech.easyflow.job.execution;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.context.ApplicationContext;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.service.WorkflowJobExecutionService;
import tech.easyflow.system.entity.SysAccount;
import java.math.BigInteger;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
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 SysJobInvokerTest {
@Test
public void springBeanInvocationMustUseJdkProxyPublicMethod() throws Exception {
Fixture fixture = fixture(EnumJobType.SPRING_BEAN);
EchoService target = value -> "echo:" + value;
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setInterfaces(EchoService.class);
when(fixture.applicationContext().getBean("echoService"))
.thenReturn(proxyFactory.getProxy());
fixture.snapshot().setJobParams(Map.of(
JobConstant.BEAN_METHOD_KEY, "echoService.echo(\"hello\")"));
Object result = fixture.invoker().execute(fixture.snapshot());
Assert.assertEquals("echo:hello", result);
verify(fixture.ownerValidator()).requireAvailableOwner(fixture.current());
}
@Test
public void springBeanInvocationMustUseCglibProxyPublicMethod() throws Exception {
Fixture fixture = fixture(EnumJobType.SPRING_BEAN);
ProxyFactory proxyFactory = new ProxyFactory(new EchoServiceImpl());
proxyFactory.setProxyTargetClass(true);
when(fixture.applicationContext().getBean("echoService"))
.thenReturn(proxyFactory.getProxy());
fixture.snapshot().setJobParams(Map.of(
JobConstant.BEAN_METHOD_KEY, "echoService.echo(\"hello\")"));
Object result = fixture.invoker().execute(fixture.snapshot());
Assert.assertEquals("echo:hello", result);
}
@Test
public void javaClassInvocationMustRejectPrivateMethod() {
Fixture fixture = fixture(EnumJobType.JAVA_CLASS);
fixture.snapshot().setJobParams(Map.of(
JobConstant.JAVA_METHOD_KEY,
PrivateJavaTask.class.getName() + ".hidden()"));
NoSuchMethodException exception = Assert.assertThrows(
NoSuchMethodException.class,
() -> fixture.invoker().execute(fixture.snapshot()));
Assert.assertTrue(exception.getMessage().contains("hidden"));
}
@Test
public void invalidOwnerMustBlockSpringBeanBeforeLookup() {
Fixture fixture = fixture(EnumJobType.SPRING_BEAN);
fixture.snapshot().setJobParams(Map.of(
JobConstant.BEAN_METHOD_KEY, "echoService.echo(\"hello\")"));
when(fixture.ownerValidator().requireAvailableOwner(fixture.current()))
.thenThrow(new IllegalStateException("owner disabled"));
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> fixture.invoker().execute(fixture.snapshot()));
Assert.assertEquals("owner disabled", exception.getMessage());
verify(fixture.applicationContext(), never()).getBean(any(String.class));
}
private static Fixture fixture(EnumJobType type) {
SysJobMapper jobMapper = mock(SysJobMapper.class);
ApplicationContext applicationContext = mock(ApplicationContext.class);
WorkflowJobExecutionService workflowExecutionService =
mock(WorkflowJobExecutionService.class);
SysJobOwnerValidator ownerValidator = mock(SysJobOwnerValidator.class);
SysJob snapshot = job(101L, type);
SysJob current = job(101L, type);
current.setStatus(EnumJobStatus.RUNNING.getCode());
current.setCreatedBy(BigInteger.valueOf(301L));
SysAccount owner = new SysAccount();
owner.setId(current.getCreatedBy());
owner.setTenantId(current.getTenantId());
when(jobMapper.selectOneById(snapshot.getId())).thenReturn(current);
when(ownerValidator.requireAvailableOwner(current)).thenReturn(owner);
return new Fixture(
new SysJobInvoker(jobMapper, applicationContext,
workflowExecutionService, ownerValidator),
applicationContext, ownerValidator, snapshot, current);
}
private static SysJob job(long id, EnumJobType type) {
SysJob job = new SysJob();
job.setId(BigInteger.valueOf(id));
job.setTenantId(BigInteger.valueOf(201L));
job.setJobType(type.getCode());
return job;
}
public interface EchoService {
String echo(String value);
}
public static class EchoServiceImpl implements EchoService {
@Override
public String echo(String value) {
return "echo:" + value;
}
}
public static class PrivateJavaTask {
private String hidden() {
return "hidden";
}
}
private record Fixture(SysJobInvoker invoker,
ApplicationContext applicationContext,
SysJobOwnerValidator ownerValidator,
SysJob snapshot,
SysJob current) {
}
}

View File

@@ -0,0 +1,778 @@
package tech.easyflow.job.execution;
import org.junit.After;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
/**
* 在显式提供的 MySQL 8 测试库上验证执行账本的真实事务协议。
*/
public class SysJobLedgerMySqlIntegrationTest {
private String jdbcUrl;
private String username;
private String password;
private long jobId;
private long firstExecutionId;
private long secondExecutionId;
private long registrationExecutionId;
private long generationExecutionId;
@Before
public void setUp() throws Exception {
jdbcUrl = System.getenv("EASYFLOW_JOB_IT_JDBC_URL");
username = System.getenv("EASYFLOW_JOB_IT_USERNAME");
password = System.getenv("EASYFLOW_JOB_IT_PASSWORD");
Assume.assumeTrue("未配置真实 MySQL 集成测试环境",
jdbcUrl != null && username != null && password != null);
long suffix = Math.abs(UUID.randomUUID().getMostSignificantBits() % 100_000_000L);
jobId = 8_100_000_000_000_000L + suffix * 10L;
firstExecutionId = jobId + 1L;
secondExecutionId = jobId + 2L;
registrationExecutionId = jobId + 3L;
generationExecutionId = jobId + 4L;
try (Connection connection = open(); Statement statement = connection.createStatement()) {
try (ResultSet result = statement.executeQuery("SELECT VERSION()")) {
Assert.assertTrue(result.next());
Assert.assertTrue("需要 MySQL 8实际为 " + result.getString(1),
result.getString(1).startsWith("8."));
}
insertFixtures(connection);
}
}
@After
public void tearDown() throws Exception {
if (jdbcUrl == null || jobId == 0L) return;
try (Connection connection = open(); PreparedStatement logs = connection.prepareStatement(
"DELETE FROM tb_sys_job_log WHERE job_id=?");
PreparedStatement job = connection.prepareStatement(
"DELETE FROM tb_sys_job WHERE id=?")) {
logs.setLong(1, jobId);
logs.executeUpdate();
job.setLong(1, jobId);
job.executeUpdate();
}
}
@Test
public void shouldEnforceSkipLockedNonConcurrencyAndFencing() throws Exception {
verifySkipLockedClaim();
resetExecutions();
verifyUnlockedExpiredProbeDoesNotDeadlockPendingClaims();
resetExecutions();
verifyLockedExpiredCandidateDoesNotStarvePendingClaim();
resetExecutions();
verifyNonConcurrentJobRowLock();
verifyOwnerTokenFencing();
verifyManagementCommandRowLock();
verifyManagementCommitsBeforeLedgerCleanup();
verifyCleanupUsesLedgerThenJobLock();
verifyRegistrationWaitsForStartCommit();
verifyStaleCleanupWaitsForStartCommit();
verifyDefinitionEditCannotResurrectConcurrentStop();
verifyRestartClearsOldPendingGeneration();
verifyOldFireCannotCrossRestartGeneration();
verifyOldFireCannotCrossDefinitionUpdate();
verifyRegisteredExecutionSurvivesDefinitionGeneration();
verifyExpiredRegisteredExecutionSurvivesDefinitionGeneration();
verifyDuplicateExecutionKeyCanBeReadInSameTransaction();
}
private void verifySkipLockedClaim() throws Exception {
try (Connection first = transactional(); Connection second = transactional()) {
Assert.assertEquals(firstExecutionId, selectPendingForUpdate(first));
Assert.assertEquals(secondExecutionId, selectPendingForUpdate(second));
Assert.assertEquals(1, claim(first, firstExecutionId, "node-a", "token-a"));
first.commit();
Assert.assertEquals(0, claim(second, firstExecutionId, "node-b", "token-b"));
second.rollback();
}
}
private void verifyUnlockedExpiredProbeDoesNotDeadlockPendingClaims() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (Connection first = transactional(); Connection second = transactional()) {
Assert.assertEquals(0L, selectExpiredWithoutLock(first));
Assert.assertEquals(0L, selectExpiredWithoutLock(second));
Assert.assertEquals(firstExecutionId, selectPendingForUpdate(first));
Assert.assertEquals(secondExecutionId, selectPendingForUpdate(second));
Future<Integer> firstClaim = executor.submit(() -> {
ready.countDown();
start.await(5, TimeUnit.SECONDS);
int claimed = claim(first, firstExecutionId, "node-a", "probe-a");
first.commit();
return claimed;
});
Future<Integer> secondClaim = executor.submit(() -> {
ready.countDown();
start.await(5, TimeUnit.SECONDS);
int claimed = claim(second, secondExecutionId, "node-b", "probe-b");
second.commit();
return claimed;
});
Assert.assertTrue(ready.await(5, TimeUnit.SECONDS));
start.countDown();
Assert.assertEquals(Integer.valueOf(1), firstClaim.get(5, TimeUnit.SECONDS));
Assert.assertEquals(Integer.valueOf(1), secondClaim.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
private void verifyLockedExpiredCandidateDoesNotStarvePendingClaim() throws Exception {
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=3,lease_owner='locked-owner',"
+ "execution_token='locked-token',"
+ "lease_until=TIMESTAMPADD(SECOND,-1,CURRENT_TIMESTAMP(3)) WHERE id=?")) {
statement.setLong(1, firstExecutionId);
Assert.assertEquals(1, statement.executeUpdate());
}
try (Connection lockOwner = transactional(); Connection claimant = transactional()) {
lockExecution(lockOwner, firstExecutionId);
Assert.assertEquals("无锁探测应仍能看见已提交的过期记录",
firstExecutionId, selectExpiredWithoutLock(claimant));
Assert.assertEquals("SKIP LOCKED 应跳过其他 Worker 持有的过期记录",
0L, selectExpiredForUpdate(claimant));
Assert.assertEquals("受阻的过期记录不得阻塞 PENDING 队列",
secondExecutionId, selectPendingForUpdate(claimant));
Assert.assertEquals(1,
claim(claimant, secondExecutionId, "pending-node", "pending-token"));
claimant.commit();
lockOwner.rollback();
}
}
private void verifyNonConcurrentJobRowLock() throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection first = transactional()) {
lockJob(first);
Assert.assertEquals(1, claim(first, firstExecutionId, "node-a", "token-a"));
Future<Integer> secondNode = executor.submit(() -> {
try (Connection second = transactional()) {
lockJob(second);
int active = countOtherActive(second, secondExecutionId);
second.commit();
return active;
}
});
Thread.sleep(200L);
Assert.assertFalse("第二节点不应越过同任务行锁", secondNode.isDone());
first.commit();
Assert.assertEquals(Integer.valueOf(1), secondNode.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
private void verifyOwnerTokenFencing() throws Exception {
try (Connection connection = open()) {
try (PreparedStatement takeover = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=3, lease_owner='new-owner', "
+ "execution_token='new-token', lease_until=TIMESTAMPADD(SECOND,60,CURRENT_TIMESTAMP(3)) "
+ "WHERE id=?")) {
takeover.setLong(1, firstExecutionId);
Assert.assertEquals(1, takeover.executeUpdate());
}
Assert.assertEquals(0, finish(connection, "old-owner", "old-token"));
Assert.assertEquals(1, finish(connection, "new-owner", "new-token"));
}
}
private void verifyManagementCommandRowLock() throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection first = transactional()) {
lockJob(first);
updateJobStatus(first, 0);
Future<Integer> secondCommand = executor.submit(() -> {
try (Connection second = transactional()) {
lockJob(second);
int observed = readJobStatus(second);
updateJobStatus(second, 1);
second.commit();
return observed;
}
});
Thread.sleep(200L);
Assert.assertFalse("并发管理命令不应在前一事务提交前执行", secondCommand.isDone());
first.commit();
Assert.assertEquals("后一命令必须读取前一命令已提交状态",
Integer.valueOf(0), secondCommand.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
private void verifyManagementCommitsBeforeLedgerCleanup() throws Exception {
resetExecutions();
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection worker = transactional()) {
Assert.assertEquals(firstExecutionId, selectPendingForUpdate(worker));
Future<?> stopCommand = executor.submit(() -> {
try (Connection management = transactional()) {
lockJob(management);
updateJobStatus(management, 0);
management.commit();
return null;
}
});
// 管理事务不再反向触碰已被 Worker 锁定的日志行,因此应能先提交。
stopCommand.get(5, TimeUnit.SECONDS);
lockJob(worker);
Assert.assertEquals(0, readJobStatus(worker));
worker.rollback();
} finally {
executor.shutdownNow();
}
try (Connection connection = open()) {
updateJobStatus(connection, 1);
}
try (Connection cleanup = transactional()) {
Assert.assertEquals("RUNNING 任务不得清理待执行记录", 0,
cancelPendingIfStopped(cleanup));
cleanup.commit();
}
try (Connection connection = open()) {
updateJobStatus(connection, 0);
}
try (Connection cleanup = transactional()) {
Assert.assertEquals(2, cancelPendingIfStopped(cleanup));
cleanup.commit();
}
try (Connection connection = open()) {
updateJobStatus(connection, 1);
}
}
private void verifyRegistrationWaitsForStartCommit() throws Exception {
try (Connection connection = open()) {
updateJobStatus(connection, 0);
}
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection start = transactional()) {
lockJob(start);
updateJobStatus(start, 1);
Future<Integer> registration = executor.submit(() -> {
try (Connection fire = transactional()) {
lockJob(fire);
int observed = readJobStatus(fire);
if (observed == 1) insertExecution(fire, registrationExecutionId);
fire.commit();
return observed;
}
});
Thread.sleep(200L);
Assert.assertFalse("Quartz 触发登记必须等待 START 事务提交", registration.isDone());
start.commit();
Assert.assertEquals("登记必须读取 START 提交后的 RUNNING 状态",
Integer.valueOf(1), registration.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(
"SELECT COUNT(*) FROM tb_sys_job_log WHERE id=? AND status=2")) {
statement.setLong(1, registrationExecutionId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
Assert.assertEquals(1, result.getInt(1));
}
}
}
private void verifyCleanupUsesLedgerThenJobLock() throws Exception {
resetExecutions();
try (Connection connection = open()) {
updateJobStatus(connection, 0);
}
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection worker = transactional()) {
Assert.assertEquals(firstExecutionId, selectPendingForUpdate(worker));
Future<Integer> cleanup = executor.submit(() -> {
try (Connection connection = transactional()) {
int cancelled = cancelPendingIfStopped(connection);
connection.commit();
return cancelled;
}
});
Thread.sleep(200L);
Assert.assertFalse("清理应先等待 Worker 持有的账本行", cleanup.isDone());
// 清理尚未持有任务行,因此 Worker 可继续按 log -> job 顺序完成。
lockJob(worker);
worker.rollback();
Assert.assertEquals(Integer.valueOf(2), cleanup.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
private void verifyStaleCleanupWaitsForStartCommit() throws Exception {
resetExecutions();
try (Connection connection = open()) {
updateJobStatus(connection, 0);
}
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection start = transactional()) {
lockJob(start);
updateJobStatus(start, 1);
Future<Integer> staleCleanup = executor.submit(() -> {
try (Connection cleanup = transactional()) {
lockJob(cleanup);
int cancelled = cancelPendingIfStopped(cleanup);
cleanup.commit();
return cancelled;
}
});
Thread.sleep(200L);
Assert.assertFalse("旧清理命令必须等待并发 START 事务提交", staleCleanup.isDone());
start.commit();
Assert.assertEquals("旧清理命令不得取消新一代 RUNNING 记录",
Integer.valueOf(0), staleCleanup.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
private void verifyRestartClearsOldPendingGeneration() throws Exception {
resetExecutions();
try (Connection connection = open()) {
updateJobStatus(connection, 0);
}
try (Connection cleanup = transactional()) {
Assert.assertTrue("重新启动前应清理上一代全部 PENDING",
cancelPendingIfStopped(cleanup) >= 2);
cleanup.commit();
}
try (Connection start = transactional()) {
lockJob(start);
startNextGeneration(start);
start.commit();
}
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(
"SELECT COUNT(*) FROM tb_sys_job_log WHERE job_id=? AND status=2")) {
statement.setLong(1, jobId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
Assert.assertEquals("旧 PENDING 不得被新 RUNNING 代际重新放行",
0, result.getInt(1));
}
}
}
private void verifyDefinitionEditCannotResurrectConcurrentStop() throws Exception {
try (Connection connection = open()) {
updateJobStatus(connection, 1);
}
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection stop = transactional()) {
lockJob(stop);
updateJobStatus(stop, 0);
Future<Integer> edit = executor.submit(() -> {
try (Connection connection = transactional()) {
lockJob(connection);
int observedStatus = readJobStatus(connection);
advanceDefinitionGeneration(connection);
connection.commit();
return observedStatus;
}
});
Thread.sleep(200L);
Assert.assertFalse("定义编辑必须等待并发 STOP 提交", edit.isDone());
stop.commit();
Assert.assertEquals("编辑只能保留行锁下的最终 STOP 状态",
Integer.valueOf(0), edit.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
try (Connection connection = open()) {
Assert.assertEquals("普通编辑不得复活已停止任务", 0, readJobStatus(connection));
updateJobStatus(connection, 1);
}
}
private void verifyOldFireCannotCrossRestartGeneration() throws Exception {
long oldGeneration;
try (Connection connection = open()) {
oldGeneration = readJobGeneration(connection);
updateJobStatus(connection, 0);
}
try (Connection start = transactional()) {
lockJob(start);
startNextGeneration(start);
start.commit();
}
try (Connection fire = transactional()) {
Assert.assertEquals("STOP 前取得的 Quartz fire 不得登记到新启停代际", 0,
registerIfGenerationMatches(fire, generationExecutionId, oldGeneration));
fire.commit();
}
}
private void verifyOldFireCannotCrossDefinitionUpdate() throws Exception {
long oldGeneration;
try (Connection edit = transactional()) {
lockJob(edit);
oldGeneration = readJobGeneration(edit);
advanceDefinitionGeneration(edit);
edit.commit();
}
try (Connection oldFire = transactional()) {
Assert.assertEquals("编辑提交前取得的 Quartz fire 不得读取新定义并登记", 0,
registerIfGenerationMatches(oldFire, generationExecutionId + 1L, oldGeneration));
oldFire.commit();
}
try (Connection currentFire = transactional()) {
long currentGeneration = readJobGeneration(currentFire);
Assert.assertEquals(oldGeneration + 1L, currentGeneration);
Assert.assertEquals("当前定义代际的 fire 应可正常登记", 1,
registerIfGenerationMatches(
currentFire, generationExecutionId + 2L, currentGeneration));
currentFire.commit();
}
}
private void verifyRegisteredExecutionSurvivesDefinitionGeneration() throws Exception {
long oldGeneration;
try (Connection connection = open()) {
oldGeneration = readJobGeneration(connection);
insertExecution(connection, generationExecutionId + 3L, oldGeneration);
}
try (Connection edit = transactional()) {
lockJob(edit);
advanceDefinitionGeneration(edit);
edit.commit();
}
try (Connection connection = open()) {
Assert.assertTrue(readJobGeneration(connection) > oldGeneration);
Assert.assertEquals("已登记快照不得因后续编辑失去领取资格", 1,
claim(connection, generationExecutionId + 3L,
"generation-node", "generation-token"));
}
}
private void verifyExpiredRegisteredExecutionSurvivesDefinitionGeneration() throws Exception {
long executionId = generationExecutionId + 4L;
long oldGeneration;
try (Connection connection = open()) {
oldGeneration = readJobGeneration(connection);
insertExecution(connection, executionId, oldGeneration);
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=3,lease_owner='expired-owner',"
+ "execution_token='expired-token',"
+ "lease_until=TIMESTAMPADD(SECOND,-1,CURRENT_TIMESTAMP(3)) WHERE id=?")) {
statement.setLong(1, executionId);
Assert.assertEquals(1, statement.executeUpdate());
}
}
try (Connection edit = transactional()) {
lockJob(edit);
advanceDefinitionGeneration(edit);
edit.commit();
}
try (Connection takeover = transactional()) {
Assert.assertEquals("已登记的过期 RUNNING 不得因定义代际变化失去接管资格",
executionId, selectExpiredForUpdate(takeover));
Assert.assertEquals(1, claimExpired(
takeover, executionId, "takeover-owner", "takeover-token"));
takeover.commit();
}
try (Connection connection = open()) {
Assert.assertEquals("旧租约持有者不得覆盖接管后的终态", 0,
finish(connection, executionId, "expired-owner", "expired-token"));
Assert.assertEquals(1,
finish(connection, executionId, "takeover-owner", "takeover-token"));
}
}
private void verifyDuplicateExecutionKeyCanBeReadInSameTransaction() throws Exception {
long originalId = generationExecutionId + 10L;
long conflictingId = generationExecutionId + 11L;
String executionKey = String.format("%064x", originalId);
try (Connection connection = open()) {
insertExecution(connection, originalId, readJobGeneration(connection), executionKey);
}
try (Connection connection = transactional()) {
SQLException duplicate = Assert.assertThrows(
SQLException.class,
() -> insertExecution(
connection, conflictingId, readJobGeneration(connection), executionKey));
Assert.assertEquals("23000", duplicate.getSQLState());
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job_log WHERE execution_key=?")) {
statement.setString(1, executionKey);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue("唯一键冲突后同一事务仍应能复查 execution_key",
result.next());
Assert.assertEquals(originalId, result.getLong(1));
}
}
connection.rollback();
}
}
private void insertFixtures(Connection connection) throws SQLException {
try (PreparedStatement job = connection.prepareStatement(
"INSERT INTO tb_sys_job(id,dept_id,tenant_id,job_name,job_type,job_params,"
+ "cron_expression,allow_concurrent,misfire_policy,options,status,created,"
+ "schedule_generation,created_by,modified,modified_by,remark) "
+ "VALUES(?,1,1,'L25 MySQL IT',3,'{}','0 0 0 1 1 ? 2099',0,3,'{}',1,NOW(),0,1,NOW(),1,'')")) {
job.setLong(1, jobId);
job.executeUpdate();
}
insertExecution(connection, firstExecutionId, 0L);
insertExecution(connection, secondExecutionId, 0L);
}
private void insertExecution(Connection connection, long id) throws SQLException {
insertExecution(connection, id, readJobGeneration(connection));
}
private void insertExecution(Connection connection, long id, long generation) throws SQLException {
insertExecution(connection, id, generation, String.format("%064x", id));
}
private void insertExecution(Connection connection, long id, long generation,
String executionKey) throws SQLException {
try (PreparedStatement execution = connection.prepareStatement(
"INSERT INTO tb_sys_job_log(id,execution_key,job_id,job_generation,tenant_id,dept_id,job_name,"
+ "job_type,job_params,job_options,allow_concurrent,trigger_source,"
+ "scheduled_fire_time,actual_fire_time,fire_instance_id,recovering,"
+ "attempt_count,next_retry_time,version,status,created,remark) "
+ "VALUES(?,?,?,?,1,1,'L25 MySQL IT',3,'{}','{}',0,'MANUAL',"
+ "CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),?,0,0,CURRENT_TIMESTAMP(3),0,2,NOW(),'')")) {
execution.setLong(1, id);
execution.setString(2, executionKey);
execution.setLong(3, jobId);
execution.setLong(4, generation);
execution.setString(5, "fire-" + id);
execution.executeUpdate();
}
}
private int registerIfGenerationMatches(Connection connection, long executionId,
long fireGeneration) throws SQLException {
lockJob(connection);
if (readJobStatus(connection) != 1
|| readJobGeneration(connection) != fireGeneration) {
return 0;
}
insertExecution(connection, executionId, fireGeneration);
return 1;
}
private void startNextGeneration(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job SET status=1,schedule_generation=schedule_generation+1 "
+ "WHERE id=? AND status<>1")) {
statement.setLong(1, jobId);
Assert.assertEquals(1, statement.executeUpdate());
}
}
private void advanceDefinitionGeneration(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job SET schedule_generation=schedule_generation+1 WHERE id=?")) {
statement.setLong(1, jobId);
Assert.assertEquals(1, statement.executeUpdate());
}
}
private void resetExecutions() throws SQLException {
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=2,lease_owner=NULL,execution_token=NULL,"
+ "lease_until=NULL,next_retry_time=CURRENT_TIMESTAMP(3) WHERE job_id=?")) {
statement.setLong(1, jobId);
statement.executeUpdate();
}
}
private long selectPendingForUpdate(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job_log FORCE INDEX(PRIMARY) WHERE id IN (?,?) "
+ "AND job_id=? AND status=2 "
+ "ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")) {
statement.setLong(1, firstExecutionId);
statement.setLong(2, secondExecutionId);
statement.setLong(3, jobId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
return result.getLong(1);
}
}
}
private long selectExpiredForUpdate(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job_log WHERE status=3 "
+ "AND lease_until<=CURRENT_TIMESTAMP(3) "
+ "ORDER BY lease_until,id LIMIT 1 FOR UPDATE SKIP LOCKED")) {
try (ResultSet result = statement.executeQuery()) {
return result.next() ? result.getLong(1) : 0L;
}
}
}
private void lockExecution(Connection connection, long executionId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job_log WHERE id=? FOR UPDATE")) {
statement.setLong(1, executionId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
}
}
}
private long selectExpiredWithoutLock(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job_log WHERE status=3 "
+ "AND lease_until<=CURRENT_TIMESTAMP(3) "
+ "ORDER BY lease_until,id LIMIT 1")) {
try (ResultSet result = statement.executeQuery()) {
return result.next() ? result.getLong(1) : 0L;
}
}
}
private int claim(Connection connection, long id, String owner, String token) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=3,lease_owner=?,execution_token=?,"
+ "lease_until=TIMESTAMPADD(SECOND,60,CURRENT_TIMESTAMP(3)) "
+ "WHERE id=? AND status=2")) {
statement.setString(1, owner);
statement.setString(2, token);
statement.setLong(3, id);
return statement.executeUpdate();
}
}
private int claimExpired(Connection connection, long id, String owner,
String token) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET lease_owner=?,execution_token=?,"
+ "lease_until=TIMESTAMPADD(SECOND,60,CURRENT_TIMESTAMP(3)),"
+ "attempt_count=attempt_count+1 WHERE id=? AND status=3 "
+ "AND lease_until<=CURRENT_TIMESTAMP(3)")) {
statement.setString(1, owner);
statement.setString(2, token);
statement.setLong(3, id);
return statement.executeUpdate();
}
}
private void lockJob(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job WHERE id=? FOR UPDATE")) {
statement.setLong(1, jobId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
}
}
}
private int countOtherActive(Connection connection, long excludedId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT COUNT(*) FROM tb_sys_job_log WHERE job_id=? AND id<>? AND status=3 "
+ "AND lease_until>CURRENT_TIMESTAMP(3) FOR UPDATE")) {
statement.setLong(1, jobId);
statement.setLong(2, excludedId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
return result.getInt(1);
}
}
}
private int finish(Connection connection, String owner, String token) throws SQLException {
return finish(connection, firstExecutionId, owner, token);
}
private int finish(Connection connection, long executionId,
String owner, String token) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=1,end_time=CURRENT_TIMESTAMP(3) WHERE id=? "
+ "AND status=3 AND lease_owner=? AND execution_token=?")) {
statement.setLong(1, executionId);
statement.setString(2, owner);
statement.setString(3, token);
return statement.executeUpdate();
}
}
private int cancelPendingIfStopped(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id FROM tb_sys_job_log WHERE job_id=? AND status=2 "
+ "ORDER BY id FOR UPDATE")) {
statement.setLong(1, jobId);
try (ResultSet ignored = statement.executeQuery()) {
while (ignored.next()) {
// 读取完整结果集并保持行锁到事务结束。
}
}
}
lockJob(connection);
if (readJobStatus(connection) != 0) return 0;
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job_log SET status=5 WHERE job_id=? AND status=2")) {
statement.setLong(1, jobId);
return statement.executeUpdate();
}
}
private void updateJobStatus(Connection connection, int status) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE tb_sys_job SET status=? WHERE id=?")) {
statement.setInt(1, status);
statement.setLong(2, jobId);
Assert.assertEquals(1, statement.executeUpdate());
}
}
private int readJobStatus(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT status FROM tb_sys_job WHERE id=?")) {
statement.setLong(1, jobId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
return result.getInt(1);
}
}
}
private long readJobGeneration(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT schedule_generation FROM tb_sys_job WHERE id=?")) {
statement.setLong(1, jobId);
try (ResultSet result = statement.executeQuery()) {
Assert.assertTrue(result.next());
return result.getLong(1);
}
}
}
private Connection transactional() throws SQLException {
Connection connection = open();
connection.setAutoCommit(false);
connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
return connection;
}
private Connection open() throws SQLException {
return DriverManager.getConnection(jdbcUrl, username, password);
}
}

View File

@@ -0,0 +1,75 @@
package tech.easyflow.job.execution;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class SysJobOwnerValidatorTest {
@Test
public void missingOwnerMustBeRejected() {
SysAccountService accountService = mock(SysAccountService.class);
SysJob job = job();
when(accountService.getById(job.getCreatedBy())).thenReturn(null);
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> new SysJobOwnerValidator(accountService).requireAvailableOwner(job));
Assert.assertTrue(exception.getMessage().contains("不存在"));
}
@Test
public void disabledOwnerMustBeRejected() {
SysAccountService accountService = mock(SysAccountService.class);
SysJob job = job();
SysAccount account = account(job.getCreatedBy(), job.getTenantId(), -1);
when(accountService.getById(job.getCreatedBy())).thenReturn(account);
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> new SysJobOwnerValidator(accountService).requireAvailableOwner(job));
Assert.assertTrue(exception.getMessage().contains("未启用"));
}
@Test
public void crossTenantOwnerMustBeRejected() {
SysAccountService accountService = mock(SysAccountService.class);
SysJob job = job();
SysAccount account = account(
job.getCreatedBy(), BigInteger.valueOf(999L),
EnumDataStatus.AVAILABLE.getCode());
when(accountService.getById(job.getCreatedBy())).thenReturn(account);
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> new SysJobOwnerValidator(accountService).requireAvailableOwner(job));
Assert.assertTrue(exception.getMessage().contains("租户"));
}
private static SysJob job() {
SysJob job = new SysJob();
job.setId(BigInteger.valueOf(101L));
job.setTenantId(BigInteger.valueOf(201L));
job.setCreatedBy(BigInteger.valueOf(301L));
return job;
}
private static SysAccount account(BigInteger id, BigInteger tenantId, int status) {
SysAccount account = new SysAccount();
account.setId(id);
account.setTenantId(tenantId);
account.setStatus(status);
return account;
}
}

View File

@@ -0,0 +1,54 @@
package tech.easyflow.job.mapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.job.entity.SysJobLog;
import java.math.BigInteger;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class SysJobLogMapperTest {
@Test
public void pendingClaimShouldReadLockedIdThroughEntityResultMap() {
SysJobLogMapper mapper = mock(SysJobLogMapper.class, CALLS_REAL_METHODS);
BigInteger id = BigInteger.valueOf(31L);
SysJobLog expected = execution(id);
doReturn(id).when(mapper).selectPendingClaimCandidateId();
doReturn(expected).when(mapper).selectOneById(id);
Assert.assertSame(expected, mapper.selectPendingClaimCandidate());
verify(mapper).selectOneById(id);
}
@Test
public void expiredClaimShouldReadLockedIdThroughEntityResultMap() {
SysJobLogMapper mapper = mock(SysJobLogMapper.class, CALLS_REAL_METHODS);
BigInteger id = BigInteger.valueOf(32L);
SysJobLog expected = execution(id);
doReturn(id).when(mapper).selectExpiredClaimCandidateId();
doReturn(expected).when(mapper).selectOneById(id);
Assert.assertSame(expected, mapper.selectExpiredClaimCandidate());
verify(mapper).selectOneById(id);
}
@Test
public void emptyClaimShouldNotIssueEntityRead() {
SysJobLogMapper mapper = mock(SysJobLogMapper.class, CALLS_REAL_METHODS);
doReturn(null).when(mapper).selectPendingClaimCandidateId();
Assert.assertNull(mapper.selectPendingClaimCandidate());
}
private static SysJobLog execution(BigInteger id) {
SysJobLog execution = new SysJobLog();
execution.setId(id);
execution.setJobId(BigInteger.TEN);
return execution;
}
}

View File

@@ -0,0 +1,65 @@
package tech.easyflow.job.mapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.job.entity.SysJob;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class SysJobMapperTest {
@Test
public void lockQueryShouldDelegateToEntityMappedBaseMapper() {
SysJobMapper mapper = mock(SysJobMapper.class, CALLS_REAL_METHODS);
SysJob expected = job(17L);
doReturn(expected).when(mapper).selectOneByQuery(any(QueryWrapper.class));
SysJob actual = mapper.selectByIdForUpdate(BigInteger.valueOf(17L));
Assert.assertSame(expected, actual);
ArgumentCaptor<QueryWrapper> query = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectOneByQuery(query.capture());
String sql = normalizedSql(query.getValue());
Assert.assertTrue(sql.contains("ID = 17"));
Assert.assertTrue(sql.contains("FOR UPDATE"));
}
@Test
public void reconciliationShouldUseEntityMappingAndKeysetPage() {
SysJobMapper mapper = mock(SysJobMapper.class, CALLS_REAL_METHODS);
List<SysJob> expected = List.of(job(18L));
doReturn(expected).when(mapper).selectListByQuery(any(QueryWrapper.class));
List<SysJob> actual = mapper.selectReconciliationPage(
BigInteger.valueOf(17L), 200);
Assert.assertSame(expected, actual);
ArgumentCaptor<QueryWrapper> query = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectListByQuery(query.capture());
String sql = normalizedSql(query.getValue());
Assert.assertTrue(sql.contains("ID > 17"));
Assert.assertTrue(sql.contains("ORDER BY ID"));
Assert.assertTrue(sql.contains("LIMIT 200"));
}
private static SysJob job(long id) {
SysJob job = new SysJob();
job.setId(BigInteger.valueOf(id));
job.setScheduleGeneration(1L);
return job;
}
private static String normalizedSql(QueryWrapper query) {
return query.toSQL().replace("`", "").toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,84 @@
package tech.easyflow.job.schedule;
import com.easyagents.scheduler.ConcurrencyPolicy;
import com.easyagents.scheduler.MisfirePolicy;
import com.easyagents.scheduler.ScheduleDefinition;
import com.easyagents.scheduler.ScheduleService;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import java.math.BigInteger;
import static org.mockito.Mockito.mock;
public class SysJobScheduleAdapterTest {
@Test
public void shouldMapBusinessDefinitionToStableSchedule() {
SysJob job = new SysJob();
job.setId(BigInteger.valueOf(42));
job.setJobName("daily-report");
job.setCronExpression("0 0 2 * * ?");
job.setMisfirePolicy(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode());
job.setAllowConcurrent(0);
job.setScheduleGeneration(7L);
ScheduleDefinition definition = new SysJobScheduleAdapter(
mock(ScheduleService.class), "Asia/Shanghai").toDefinition(job);
Assert.assertEquals("easyflow.job", definition.id().namespace());
Assert.assertEquals("42", definition.id().name());
Assert.assertEquals("easyflow.job.execution", definition.handlerCode());
Assert.assertEquals(MisfirePolicy.FIRE_ONCE_NOW, definition.misfirePolicy());
Assert.assertEquals(ConcurrencyPolicy.DISALLOW, definition.concurrencyPolicy());
Assert.assertTrue(definition.recoverOnNodeFailure());
Assert.assertEquals("42", definition.parameters().get("jobId"));
Assert.assertEquals("7",
definition.parameters().get(JobConstant.SCHEDULE_GENERATION));
}
@Test
public void shouldMapSkipAndConcurrentPolicies() {
SysJob job = new SysJob();
job.setId(BigInteger.ONE);
job.setJobName("parallel");
job.setCronExpression("0/5 * * * * ?");
job.setMisfirePolicy(EnumMisfirePolicy.SKIP.getCode());
job.setAllowConcurrent(1);
job.setScheduleGeneration(0L);
ScheduleDefinition definition = new SysJobScheduleAdapter(
mock(ScheduleService.class), "UTC").toDefinition(job);
Assert.assertEquals(MisfirePolicy.SKIP, definition.misfirePolicy());
Assert.assertEquals(ConcurrencyPolicy.ALLOW, definition.concurrencyPolicy());
}
@Test(expected = IllegalArgumentException.class)
public void shouldRejectUnsupportedMisfirePolicy() {
SysJob job = new SysJob();
job.setId(BigInteger.ONE);
job.setJobName("invalid-policy");
job.setCronExpression("0/5 * * * * ?");
job.setMisfirePolicy(99);
job.setAllowConcurrent(0);
job.setScheduleGeneration(0L);
new SysJobScheduleAdapter(mock(ScheduleService.class), "UTC").toDefinition(job);
}
@Test(expected = IllegalArgumentException.class)
public void shouldRejectMissingScheduleGeneration() {
SysJob job = new SysJob();
job.setId(BigInteger.ONE);
job.setJobName("missing-generation");
job.setCronExpression("0/5 * * * * ?");
job.setMisfirePolicy(EnumMisfirePolicy.SKIP.getCode());
job.setAllowConcurrent(0);
new SysJobScheduleAdapter(mock(ScheduleService.class), "UTC").toDefinition(job);
}
}

View File

@@ -0,0 +1,46 @@
package tech.easyflow.job.schedule;
import org.junit.Test;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.service.SysJobService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class SysJobScheduleReconcilerTest {
@Test
public void reconciliationMustUseLastSeenIdInsteadOfMutableOffset() {
SysJobMapper mapper = mock(SysJobMapper.class);
SysJobService service = mock(SysJobService.class);
List<SysJob> firstPage = jobs(1, 200);
List<SysJob> secondPage = jobs(201, 1);
when(mapper.selectReconciliationPage(BigInteger.ZERO, 200)).thenReturn(firstPage);
when(mapper.selectReconciliationPage(BigInteger.valueOf(200), 200))
.thenReturn(secondPage);
new SysJobScheduleReconciler(
mapper, service, new SysJobExecutionProperties()).reconcile();
verify(mapper).selectReconciliationPage(BigInteger.ZERO, 200);
verify(mapper).selectReconciliationPage(BigInteger.valueOf(200), 200);
verify(service).syncJob(BigInteger.valueOf(201));
}
private static List<SysJob> jobs(long firstId, int count) {
List<SysJob> jobs = new ArrayList<>(count);
for (long id = firstId; id < firstId + count; id++) {
SysJob job = new SysJob();
job.setId(BigInteger.valueOf(id));
jobs.add(job);
}
return jobs;
}
}

View File

@@ -4,6 +4,7 @@ import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
@@ -14,7 +15,6 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Map;
@@ -42,25 +42,23 @@ public class WorkflowJobExecutionServiceTest {
BigInteger tenantId = BigInteger.valueOf(201);
BigInteger accountId = BigInteger.valueOf(301);
BigInteger workflowId = BigInteger.valueOf(401);
SysJobService jobService = mock(SysJobService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
SysJob currentJob = workflowJob(jobId, tenantId, accountId, workflowId);
SysAccount account = account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode());
when(jobService.getById(jobId)).thenReturn(currentJob);
when(accountService.getById(accountId)).thenReturn(account);
Map<String, Object> executionResult = Map.of("status", "done");
when(chainExecutor.execute(eq(workflowId.toString()), anyMap()))
String publishedWorkflowId = PublishedWorkflowDefinitionIds.published(workflowId.toString());
when(chainExecutor.execute(eq(publishedWorkflowId), anyMap()))
.thenReturn(executionResult);
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
jobService,
accountService,
authorizationService,
chainExecutor);
Object result = service.execute(scheduledJob(jobId, tenantId));
Object result = service.execute(
scheduledJob(jobId, tenantId, workflowId, Map.of("question", "hello")),
currentJob,
account);
Assert.assertSame(executionResult, result);
ArgumentCaptor<LoginAccount> accountCaptor =
@@ -75,7 +73,7 @@ public class WorkflowJobExecutionServiceTest {
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> paramsCaptor =
ArgumentCaptor.forClass(Map.class);
verify(chainExecutor).execute(eq(workflowId.toString()), paramsCaptor.capture());
verify(chainExecutor).execute(eq(publishedWorkflowId), paramsCaptor.capture());
Object loginUser = paramsCaptor.getValue().get(Constants.LOGIN_USER_KEY);
Assert.assertTrue(loginUser instanceof LoginAccount);
Assert.assertEquals(((LoginAccount) loginUser).getId(), accountId);
@@ -90,29 +88,28 @@ public class WorkflowJobExecutionServiceTest {
BigInteger tenantId = BigInteger.valueOf(202);
BigInteger accountId = BigInteger.valueOf(302);
BigInteger workflowId = BigInteger.valueOf(402);
SysJobService jobService = mock(SysJobService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
when(jobService.getById(jobId))
.thenReturn(workflowJob(jobId, tenantId, accountId, workflowId));
when(accountService.getById(accountId))
.thenReturn(account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode()));
SysJob currentJob = workflowJob(jobId, tenantId, accountId, workflowId);
SysAccount owner = account(
accountId, tenantId, EnumDataStatus.AVAILABLE.getCode());
when(authorizationService.requireUsableWorkflow(
eq(workflowId),
any(LoginAccount.class),
anyString()))
.thenThrow(new BusinessException("工作流权限已撤销"));
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
jobService,
accountService,
authorizationService,
chainExecutor);
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> service.execute(scheduledJob(jobId, tenantId))
() -> service.execute(
scheduledJob(jobId, tenantId, workflowId,
Map.of("question", "hello")),
currentJob,
owner)
);
Assert.assertTrue(exception.getMessage().contains("权限已撤销"));
@@ -126,25 +123,23 @@ public class WorkflowJobExecutionServiceTest {
public void shouldRejectCrossTenantJobSnapshot() {
BigInteger jobId = BigInteger.valueOf(103);
BigInteger tenantId = BigInteger.valueOf(203);
SysJobService jobService = mock(SysJobService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
when(jobService.getById(jobId)).thenReturn(workflowJob(
jobId,
tenantId,
BigInteger.valueOf(303),
BigInteger.valueOf(403)));
BigInteger accountId = BigInteger.valueOf(303);
SysJob currentJob = workflowJob(
jobId, tenantId, accountId, BigInteger.valueOf(403));
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
jobService,
accountService,
authorizationService,
chainExecutor);
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> service.execute(scheduledJob(jobId, BigInteger.valueOf(999)))
() -> service.execute(
scheduledJob(jobId, BigInteger.valueOf(999),
BigInteger.valueOf(403), Map.of()),
currentJob,
account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode()))
);
Assert.assertTrue(exception.getMessage().contains("租户"));
@@ -154,6 +149,39 @@ public class WorkflowJobExecutionServiceTest {
anyString());
}
@Test
public void shouldExecuteRegisteredWorkflowSnapshotAfterDefinitionEdit() {
BigInteger jobId = BigInteger.valueOf(104);
BigInteger tenantId = BigInteger.valueOf(204);
BigInteger accountId = BigInteger.valueOf(304);
BigInteger oldWorkflowId = BigInteger.valueOf(404);
BigInteger newWorkflowId = BigInteger.valueOf(405);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
SysJob currentJob = workflowJob(
jobId, tenantId, accountId, newWorkflowId);
SysAccount owner = account(
accountId, tenantId, EnumDataStatus.AVAILABLE.getCode());
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
authorizationService, chainExecutor);
service.execute(
scheduledJob(jobId, tenantId, oldWorkflowId,
Map.of("question", "old-ledger-value")),
currentJob,
owner);
verify(authorizationService).requireUsableWorkflow(
eq(oldWorkflowId), any(LoginAccount.class), anyString());
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> parameters = ArgumentCaptor.forClass(Map.class);
verify(chainExecutor).execute(
eq(PublishedWorkflowDefinitionIds.published(oldWorkflowId.toString())),
parameters.capture());
Assert.assertEquals(parameters.getValue().get("question"), "old-ledger-value");
}
/**
* 创建 Quartz 任务快照。
*
@@ -161,11 +189,16 @@ public class WorkflowJobExecutionServiceTest {
* @param tenantId 租户 ID
* @return 任务快照
*/
private SysJob scheduledJob(BigInteger jobId, BigInteger tenantId) {
private SysJob scheduledJob(BigInteger jobId, BigInteger tenantId,
BigInteger workflowId,
Map<String, Object> workflowParams) {
SysJob job = new SysJob();
job.setId(jobId);
job.setTenantId(tenantId);
job.setJobType(EnumJobType.TINY_FLOW.getCode());
job.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, workflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, workflowParams));
return job;
}

View File

@@ -0,0 +1,204 @@
package tech.easyflow.job.service.impl;
import com.easyagents.scheduler.ScheduleService;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.transaction.CannotCreateTransactionException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.TransactionSystemException;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.config.SysJobExecutionProperties;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.execution.SysJobExecutionStore;
import tech.easyflow.job.mapper.SysJobMapper;
import tech.easyflow.job.schedule.SysJobScheduleAdapter;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.function.Supplier;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class SysJobServiceImplTest {
@Test
public void providerFailureMustStopJobAndRetryProjectionCleanup() {
Fixture fixture = fixture();
SysJob running = job(EnumJobStatus.RUNNING);
SysJob stopped = job(EnumJobStatus.STOP);
when(fixture.mapper().selectByIdForUpdate(running.getId()))
.thenReturn(running, stopped);
doReturn(true).when(fixture.service()).updateById(any(SysJob.class));
DataAccessResourceFailureException providerFailure =
new DataAccessResourceFailureException("quartz unavailable");
doThrow(providerFailure).when(fixture.scheduleAdapter()).replace(running);
RuntimeException thrown = Assert.assertThrows(
RuntimeException.class,
() -> fixture.service().syncJob(running.getId()));
Assert.assertSame(providerFailure, thrown);
ArgumentCaptor<SysJob> update = ArgumentCaptor.forClass(SysJob.class);
verify(fixture.service()).updateById(update.capture());
Assert.assertEquals(
Integer.valueOf(EnumJobStatus.STOP.getCode()), update.getValue().getStatus());
verify(fixture.scheduleAdapter()).delete(running.getId());
verify(fixture.executionStore()).cancelPending(
running.getId(), "调度投影同步失败,任务已停止");
}
@Test
public void transactionBeginFailureMustNotDeleteExistingProjection() {
Fixture fixture = fixture(new CannotCreateTransactionException("pool exhausted"));
Assert.assertThrows(
CannotCreateTransactionException.class,
() -> fixture.service().syncJob(BigInteger.ONE));
verify(fixture.scheduleAdapter(), never()).replace(any(SysJob.class));
verify(fixture.scheduleAdapter(), never()).delete(any(BigInteger.class));
}
@Test
public void transactionCommitFailureMustNotDeleteExistingProjection() {
Fixture fixture = fixture();
SysJob running = job(EnumJobStatus.RUNNING);
when(fixture.mapper().selectByIdForUpdate(running.getId())).thenReturn(running);
doThrow(new TransactionSystemException("commit failed"))
.when(fixture.transactionManager()).commit(any(TransactionStatus.class));
Assert.assertThrows(
TransactionSystemException.class,
() -> fixture.service().syncJob(running.getId()));
verify(fixture.scheduleAdapter()).replace(running);
verify(fixture.scheduleAdapter(), never()).delete(any(BigInteger.class));
verify(fixture.executionStore(), never()).cancelPending(any(BigInteger.class), anyString());
}
@Test
public void startMustAbortBeforeStateChangeWhenOldPendingCleanupFails() {
Fixture fixture = fixture();
SysJob stopped = job(EnumJobStatus.STOP);
doReturn(stopped).when(fixture.service()).getById(stopped.getId());
doThrow(new DataAccessResourceFailureException("ledger unavailable"))
.when(fixture.executionStore()).cancelPending(any(BigInteger.class), anyString());
Assert.assertThrows(
DataAccessResourceFailureException.class,
() -> fixture.service().startJob(stopped.getId()));
verify(fixture.mapper(), never()).startNextGeneration(any(BigInteger.class), anyInt());
verify(fixture.scheduleAdapter(), never()).replace(any(SysJob.class));
}
@Test
public void deleteProjectionFailureMustLeaveStoppedBusinessRow() {
Fixture fixture = fixture();
SysJob stopped = job(EnumJobStatus.STOP);
doReturn(stopped).when(fixture.service()).getById(stopped.getId());
when(fixture.mapper().selectByIdForUpdate(stopped.getId()))
.thenReturn(stopped, stopped, stopped);
doThrow(new DataAccessResourceFailureException("delete failed"))
.when(fixture.scheduleAdapter()).delete(stopped.getId());
Assert.assertThrows(
DataAccessResourceFailureException.class,
() -> fixture.service().deleteJob(List.of(stopped.getId())));
verify(fixture.service(), never()).removeById(stopped.getId());
verify(fixture.executionStore(), atLeastOnce()).cancelPending(
eq(stopped.getId()), anyString());
}
@Test
public void triggerNowMustRejectStoppedTaskWithoutCallingProvider() {
Fixture fixture = fixture();
SysJob stopped = job(EnumJobStatus.STOP);
when(fixture.mapper().selectByIdForUpdate(stopped.getId())).thenReturn(stopped);
Assert.assertThrows(
BusinessException.class,
() -> fixture.service().triggerNow(stopped.getId()));
verify(fixture.scheduleAdapter(), never()).triggerNow(any(BigInteger.class));
}
private static Fixture fixture() {
return fixture(null);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Fixture fixture(RuntimeException transactionBeginFailure) {
SysJobMapper mapper = mock(SysJobMapper.class);
SysJobScheduleAdapter scheduleAdapter = mock(SysJobScheduleAdapter.class);
SysJobExecutionStore executionStore = mock(SysJobExecutionStore.class);
ScheduleService scheduleService = mock(ScheduleService.class);
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
if (transactionBeginFailure == null) {
when(transactionManager.getTransaction(any()))
.thenReturn(mock(TransactionStatus.class));
} else {
when(transactionManager.getTransaction(any())).thenThrow(transactionBeginFailure);
}
doAnswer(invocation -> ((Supplier) invocation.getArgument(3)).get())
.when(lockExecutor).executeWithRenewingLock(
anyString(), any(Duration.class), any(Duration.class), any(Supplier.class));
SysJobExecutionProperties properties = new SysJobExecutionProperties();
TestService service = spy(new TestService(
mapper, scheduleAdapter, executionStore, scheduleService,
lockExecutor, transactionManager, properties));
return new Fixture(
service, mapper, scheduleAdapter, executionStore, transactionManager);
}
private static SysJob job(EnumJobStatus status) {
SysJob job = new SysJob();
job.setId(BigInteger.ONE);
job.setTenantId(BigInteger.TWO);
job.setStatus(status.getCode());
job.setScheduleGeneration(0L);
return job;
}
private static final class TestService extends SysJobServiceImpl {
private TestService(SysJobMapper mapper,
SysJobScheduleAdapter scheduleAdapter,
SysJobExecutionStore executionStore,
ScheduleService scheduleService,
RedisLockExecutor lockExecutor,
PlatformTransactionManager transactionManager,
SysJobExecutionProperties properties) {
super(scheduleAdapter, executionStore, scheduleService, lockExecutor,
transactionManager, properties, "Asia/Shanghai");
this.mapper = mapper;
}
}
private record Fixture(TestService service,
SysJobMapper mapper,
SysJobScheduleAdapter scheduleAdapter,
SysJobExecutionStore executionStore,
PlatformTransactionManager transactionManager) {
}
}

View File

@@ -14,6 +14,10 @@
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-api-admin</artifactId>

View File

@@ -3,12 +3,16 @@ package tech.easyflow.starter;
import org.dromara.x.file.storage.spring.EnableFileStorage;
import org.springframework.boot.actuate.autoconfigure.elasticsearch.ElasticsearchRestHealthContributorAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration;
import tech.easyflow.common.spring.BaseApp;
/**
* EasyFlow 启动入口。
*/
@SpringBootApplication(exclude = ElasticsearchRestHealthContributorAutoConfiguration.class)
@SpringBootApplication(exclude = {
ElasticsearchRestHealthContributorAutoConfiguration.class,
QuartzAutoConfiguration.class
})
@EnableFileStorage
public class MainApplication extends BaseApp {

View File

@@ -1,9 +1,8 @@
package tech.easyflow.starter;
import com.mybatisflex.core.FlexGlobalConfig;
import com.mybatisflex.core.audit.AuditMessage;
import com.mybatisflex.core.audit.AuditManager;
import com.mybatisflex.core.audit.ConsoleMessageCollector;
import com.mybatisflex.core.audit.MessageCollector;
import com.mybatisflex.core.tenant.TenantManager;
import com.mybatisflex.spring.boot.MyBatisFlexCustomizer;
import cn.dev33.satoken.stp.StpUtil;
@@ -11,18 +10,27 @@ import cn.dev33.satoken.exception.NotWebContextException;
import cn.dev33.satoken.exception.SaTokenContextException;
import cn.dev33.satoken.context.SaHolder;
import java.math.BigInteger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.job.execution.SysJobExecutionContextHolder;
@Configuration
public class MybatisConfig implements MyBatisFlexCustomizer {
private static final Logger log = LoggerFactory.getLogger(MybatisConfig.class);
private static final int MAX_AUDIT_SQL_LENGTH = 2_000;
@Value("${easyflow.mybatis.sql-audit-enabled:false}")
private boolean sqlAuditEnabled;
@Override
public void customize(FlexGlobalConfig flexGlobalConfig) {
//开启审计功能
AuditManager.setAuditEnable(true);
// SQL 审计会同步格式化并输出每条语句,高频 Worker 轮询下必须显式开启。
AuditManager.setAuditEnable(sqlAuditEnabled);
// 统一使用标准 0/1 逻辑删除语义。
flexGlobalConfig.setNormalValueOfLogicDelete(0);
@@ -34,9 +42,25 @@ public class MybatisConfig implements MyBatisFlexCustomizer {
//取消控制台的 Banner 打印
flexGlobalConfig.setPrintBanner(false);
//设置 SQL 审计收集器
MessageCollector collector = new ConsoleMessageCollector();
AuditManager.setMessageCollector(collector);
if (sqlAuditEnabled) {
AuditManager.setMessageCollector(MybatisConfig::collectSafeAuditMessage);
}
}
/**
* 审计日志只记录参数化 SQL 模板,绝不读取 AuditMessage 中的参数或完整 SQL。
*/
private static void collectSafeAuditMessage(AuditMessage message) {
log.info("SQL audit: statement={}, elapsedMs={}, rows={}, sql={}",
message.getStmtId(), message.getElapsedTime(), message.getQueryCount(),
compactSqlTemplate(message.getQuery()));
}
private static String compactSqlTemplate(String sql) {
if (sql == null) return null;
String compact = sql.replaceAll("\\s+", " ").trim();
return compact.length() <= MAX_AUDIT_SQL_LENGTH
? compact : compact.substring(0, MAX_AUDIT_SQL_LENGTH) + "...";
}
/**
@@ -45,6 +69,12 @@ public class MybatisConfig implements MyBatisFlexCustomizer {
* @return 当前租户 ID 数组;没有请求登录上下文时返回 {@code null}
*/
private Object[] currentTenantIds() {
BigInteger scheduledTenantId = SysJobExecutionContextHolder.current()
.map(context -> context.tenantId())
.orElse(null);
if (scheduledTenantId != null) {
return new Object[]{scheduledTenantId};
}
try {
if (!SaHolder.getContext().isValid() || !StpUtil.isLogin()) {
return null;

View File

@@ -25,7 +25,8 @@ spring:
username: root
password: root
hikari:
maximum-pool-size: 12
# 调度管理 Saga 会同时占用业务事务和 Quartz JobStore 连接;容量由启动校验守护。
maximum-pool-size: 24
minimum-idle: 2
connection-timeout: 5000
validation-timeout: 3000
@@ -59,34 +60,65 @@ spring:
# 静态资源路径,用于访问本地文件
# 注意,这里要和下面的 easyflow.storage.local.prefix 后面的路径 /attachment 保持一致!
static-path-pattern: /attachment/**
# quartz 相关配置
quartz:
startup-delay: 1
job-store-type: jdbc
jdbc:
platform: mysql
initialize-schema: never
properties:
org:
quartz:
scheduler:
instanceName: easyflowScheduler
instanceId: AUTO
jobStore:
misfireThreshold: 1000
isClustered: true
clusterCheckinInterval: 15000
# 如果数据库大小写敏感可将ddl里的相关表名改为大写
tablePrefix: TB_QRTZ_
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
threadPool:
threadCount: 8
threadPriority: 5
threads:
virtual:
enabled: true
easy-agents:
scheduler:
enabled: true
data-source-bean-name: dataSource
quartz:
scheduler-name: easyflowScheduler
instance-id: AUTO
table-prefix: TB_QRTZ_
driver-delegate-class: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
clustered: true
thread-count: 8
thread-priority: 5
# 短 Handler 可按可用线程批量获取同一计划时刻的 Trigger批量模式由底座强制在锁内获取。
batch-trigger-acquisition-max-count: 8
# 保持 0避免下一计划时刻的任务被同一批次提前执行。
batch-trigger-acquisition-fire-ahead-time-window-millis: 0
cluster-checkin-interval-millis: 15000
misfire-threshold-millis: 60000
wait-for-jobs-to-complete-on-shutdown: true
shutdown-wait-timeout-millis: 25000
validate-schema: true
# easy-agents 文档解析统一配置
document:
ocr:
provider: mineru
mineru:
# 统一文档解析桥接层直接复用 easy-agents 的 provider 配置,不在 easyflow 再复制一套配置体系
base-url: https://ontoweb.wust.edu.cn/mineru-api
submit-timeout-ms: 120000
default-lang-list:
- ch
easyflow:
mybatis:
# SQL 审计会同步输出脱敏后的参数化模板,仅在受控排查时临时开启。
sql-audit-enabled: ${EASYFLOW_MYBATIS_SQL_AUDIT_ENABLED:false}
job:
timezone: Asia/Shanghai
execution:
enabled: true
worker-count: 4
# 管理命令会同时占用业务事务与 Quartz JobStore 连接,需为连接池保留余量。
management-command-concurrency: 4
business-connection-reserve: 4
poll-interval: 500ms
lease-duration: 2m
heartbeat-interval: 30s
retry-backoff: 5s
shutdown-wait-timeout: 30s
infrastructure-retry-limit: 16
registration-max-attempts: 3
registration-retry-delay: 100ms
# 0 表示账本登记持续失败时保留同一次 Quartz fire直至数据库恢复。
registration-quartz-refire-limit: 0
registration-quartz-refire-delay: 250ms
datacenter:
# 数据源凭据使用独立部署密钥,禁止与仓库内其他固定密钥复用。
credential-key: cA0ldVrBRBWJlWN7BvaAGXmxH1+wCpoSE0y/iSudQsQ=
@@ -289,18 +321,6 @@ dromara:
bucket-name: easyflow-agent-artifacts
base-path: published
# easy-agents 文档解析统一配置
easy-agents:
document:
ocr:
provider: mineru
mineru:
# 统一文档解析桥接层直接复用 easy-agents 的 provider 配置,不在 easyflow 再复制一套配置体系
base-url: https://ontoweb.wust.edu.cn/mineru-api
submit-timeout-ms: 120000
default-lang-list:
- ch
# 自定义节点相关配置
node:
# 文件内容提取节点,默认使用简单文档读取器,可自行实现 ReadDocService

View File

@@ -0,0 +1,33 @@
ALTER TABLE `tb_sys_job`
ADD COLUMN `schedule_generation` bigint NOT NULL DEFAULT 0 COMMENT '调度定义代际' AFTER `status`;
ALTER TABLE `tb_sys_job_log`
ADD COLUMN `execution_key` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '执行幂等键SHA-256' AFTER `id`,
ADD COLUMN `job_generation` bigint NOT NULL COMMENT '触发所属任务代际' AFTER `job_id`,
ADD COLUMN `tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID' AFTER `job_id`,
ADD COLUMN `dept_id` bigint UNSIGNED NOT NULL COMMENT '部门ID' AFTER `tenant_id`,
ADD COLUMN `job_type` int NOT NULL COMMENT '任务类型快照' AFTER `job_name`,
ADD COLUMN `job_options` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '任务扩展配置快照' AFTER `job_params`,
ADD COLUMN `allow_concurrent` int NOT NULL DEFAULT 0 COMMENT '是否允许并发执行' AFTER `job_options`,
ADD COLUMN `trigger_source` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '触发来源' AFTER `allow_concurrent`,
ADD COLUMN `invocation_id` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '立即触发调用标识' AFTER `trigger_source`,
ADD COLUMN `scheduled_fire_time` datetime(3) NOT NULL COMMENT '计划触发时间' AFTER `trigger_source`,
ADD COLUMN `actual_fire_time` datetime(3) NOT NULL COMMENT '实际触发时间' AFTER `scheduled_fire_time`,
ADD COLUMN `fire_instance_id` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'Quartz物理触发实例' AFTER `actual_fire_time`,
ADD COLUMN `recovering` tinyint NOT NULL DEFAULT 0 COMMENT '是否为Quartz故障恢复' AFTER `fire_instance_id`,
ADD COLUMN `lease_owner` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '执行节点' AFTER `recovering`,
ADD COLUMN `execution_token` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '本轮执行令牌' AFTER `lease_owner`,
ADD COLUMN `lease_until` datetime(3) NULL COMMENT '租约到期时间' AFTER `execution_token`,
ADD COLUMN `heartbeat_time` datetime(3) NULL COMMENT '最近续租时间' AFTER `lease_until`,
ADD COLUMN `attempt_count` int NOT NULL DEFAULT 0 COMMENT '基础设施恢复次数' AFTER `heartbeat_time`,
ADD COLUMN `next_retry_time` datetime(3) NULL COMMENT '下次恢复时间' AFTER `attempt_count`,
ADD COLUMN `version` bigint NOT NULL DEFAULT 0 COMMENT '状态版本' AFTER `next_retry_time`,
MODIFY COLUMN `status` int NOT NULL DEFAULT 2 COMMENT '0失败 1成功 2等待执行 3执行中 4需人工处理 5已取消',
MODIFY COLUMN `start_time` datetime(3) NULL COMMENT '开始时间',
MODIFY COLUMN `end_time` datetime(3) NULL COMMENT '结束时间',
ADD UNIQUE KEY `uk_sys_job_log_execution_key` (`execution_key`),
ADD KEY `idx_sys_job_log_pending_claim` (`status`, `next_retry_time`, `id`),
ADD KEY `idx_sys_job_log_expired_claim` (`status`, `lease_until`, `id`),
ADD KEY `idx_sys_job_log_job_active` (`job_id`, `status`, `lease_until`),
ADD KEY `idx_sys_job_log_job_created` (`job_id`, `created`),
ADD KEY `idx_sys_job_log_tenant_id` (`tenant_id`, `id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `tb_sys_job_log`
ADD KEY `idx_sys_job_log_job_scheduled` (`job_id`, `scheduled_fire_time`, `id`),
ADD KEY `idx_sys_job_log_job_actual` (`job_id`, `actual_fire_time`, `id`);

View File

@@ -21,12 +21,11 @@ import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store';
import {
isWorkflowShareRequest,
readWorkflowShareKey,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
withWorkflowShareHeaders,
} from '#/utils/workflow-share-context';
import { refreshTokenApi } from './core';
import { isInactiveSseRequest } from './sseRequestLifecycle';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
@@ -103,12 +102,15 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers.Accept = 'application/json';
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey();
if (
workflowShareKey &&
isWorkflowShareRequest(config.url, config.method)
) {
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
const workflowShareHeaders = withWorkflowShareHeaders(
{},
{
requestMethod: config.method,
requestUrl: config.url,
},
);
for (const [name, value] of Object.entries(workflowShareHeaders)) {
config.headers[name] = value;
}
return config;
},
@@ -134,6 +136,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken ?? false,
formatToken,
shouldHandleUnauthorized: (config) =>
!isWorkflowShareRequest(config?.url, config?.method),
}),
);
@@ -188,7 +192,7 @@ export function createEventStreamHeaders(
headers[key] = value;
});
}
return withWorkflowShareHeader(headers, {
return withWorkflowShareHeaders(headers, {
requestMethod: 'POST',
requestUrl,
});
@@ -276,15 +280,25 @@ export class SseClient {
options?.onMessage?.(event);
}
} catch (innerError) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
options?.onError?.(innerError);
return;
}
// 只有在还是同一个请求的情况下才调用 onFinished
if (this.currentRequestId === currentRequestId) {
if (
!isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
options?.onFinished?.();
}
} catch (error) {
if (this.currentRequestId !== currentRequestId) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
console.error('SSE错误:', error);

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isInactiveSseRequest } from './sseRequestLifecycle';
describe('sseRequestLifecycle', () => {
it('treats an explicit abort as an inactive request', () => {
const controller = new AbortController();
controller.abort();
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(true);
});
it('treats a superseded request as inactive', () => {
const controller = new AbortController();
expect(isInactiveSseRequest(controller.signal, 2, 1)).toBe(true);
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/**
* 判断 SSE 请求是否已被主动中止或被后续请求替换。
*/
export function isInactiveSseRequest(
signal: AbortSignal,
currentRequestId: number,
requestId: number,
) {
return signal.aborted || currentRequestId !== requestId;
}

View File

@@ -122,6 +122,109 @@ describe('page data recovery', () => {
expect(get).toHaveBeenCalledTimes(2);
});
it('does not let a lightweight refresh supersede an active page query', async () => {
let resolveInitialRequest: (value: {
data: { records: { id: string }[]; totalRow: number };
}) => void = () => {};
const initialRequest = new Promise<{
data: { records: { id: string }[]; totalRow: number };
}>((resolve) => {
resolveInitialRequest = resolve;
});
const get = vi.fn().mockReturnValueOnce(initialRequest);
const wrapper = mount(PageData, {
global: {
directives: { loading: {} },
},
props: {
pageUrl: '/page',
refreshUrl: '/refresh',
requestClient: { get },
},
});
const refreshRequest = (wrapper.vm as any).reload({
lightweight: true,
silent: true,
});
expect(get).toHaveBeenCalledTimes(1);
resolveInitialRequest({
data: { records: [{ id: 'counted-result' }], totalRow: 21 },
});
await refreshRequest;
await flushPromises();
expect(get).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('loadSuccess')?.at(-1)?.[0]).toMatchObject({
lightweight: false,
recordCount: 1,
});
expect(wrapper.find('.el-pagination').exists()).toBe(true);
});
it('uses the lightweight refresh endpoint without replacing the total', async () => {
const get = vi
.fn()
.mockResolvedValueOnce({
data: { records: [{ id: 'initial' }], totalRow: 35 },
})
.mockResolvedValueOnce({ data: [{ id: 'latest' }] });
const wrapper = mount(PageData, {
global: {
directives: { loading: {} },
},
props: {
pageUrl: '/page',
refreshUrl: '/refresh',
requestClient: { get },
},
});
await flushPromises();
await (wrapper.vm as any).reload({ lightweight: true, silent: true });
await flushPromises();
expect(get).toHaveBeenLastCalledWith('/refresh', {
params: { pageNumber: 1, pageSize: 10 },
});
expect(wrapper.find('.el-pagination').exists()).toBe(true);
expect(wrapper.emitted('loadSuccess')?.at(-1)?.[0]).toMatchObject({
lightweight: true,
pageNumber: 1,
recordCount: 1,
});
});
it('falls back to the counted page endpoint outside the first page', async () => {
const get = vi
.fn()
.mockResolvedValue({ data: { records: [], totalRow: 30 } });
const wrapper = mount(PageData, {
global: {
directives: { loading: {} },
},
props: {
initialPageNumber: 2,
pageUrl: '/page',
refreshUrl: '/refresh',
requestClient: { get },
},
});
await flushPromises();
await (wrapper.vm as any).reload({ lightweight: true, silent: true });
await flushPromises();
expect(get).toHaveBeenLastCalledWith('/page', {
params: { pageNumber: 2, pageSize: 10 },
});
expect(wrapper.emitted('loadSuccess')?.at(-1)?.[0]).toMatchObject({
lightweight: false,
pageNumber: 2,
});
});
it('restores a mounted list to a new route state with one target request', async () => {
const get = vi
.fn()

View File

@@ -10,6 +10,7 @@ import { getEmptyStateImageUrl } from '#/utils/assets';
interface PageDataProps {
pageUrl: string;
refreshUrl?: string;
pageSize?: number;
pageSizes?: number[];
extraQueryParams?: Record<string, any>;
@@ -29,23 +30,38 @@ interface PageDataRestoreState extends PageDataState {
}
interface PageDataReloadOptions {
lightweight?: boolean;
silent?: boolean;
}
interface PageDataRequest {
lightweight: boolean;
silent: boolean;
version: number;
}
interface PageDataLoadEvent {
lightweight: boolean;
pageNumber: number;
recordCount: number;
}
interface PageDataLoadErrorEvent extends PageDataLoadEvent {
error: unknown;
}
const props = withDefaults(defineProps<PageDataProps>(), {
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
refreshUrl: undefined,
extraQueryParams: () => ({}),
initialPageNumber: 1,
initialQueryParams: () => ({}),
requestClient: () => api,
});
const emit = defineEmits<{
(e: 'loadError', event: PageDataLoadErrorEvent): void;
(e: 'loadSuccess', event: PageDataLoadEvent): void;
(e: 'stateChange', state: PageDataState): void;
}>();
@@ -67,10 +83,10 @@ const pageInfo = reactive({
});
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
const doGet = async (params: Record<string, any>) => {
const doGet = async (url: string, params: Record<string, any>) => {
// 这里替换为你的实际 API 调用
// 例如return await api.get(props.pageUrl, { params })
const response = await props.requestClient.get(`${props.pageUrl}`, {
const response = await props.requestClient.get(url, {
params,
});
const data = await response.data;
@@ -78,14 +94,28 @@ const doGet = async (params: Record<string, any>) => {
};
const loadPageListOnce = async (request: PageDataRequest) => {
const lightweight = Boolean(
request.lightweight && props.refreshUrl && pageInfo.pageNumber === 1,
);
try {
const res = await doGet({
const res = await doGet(lightweight ? props.refreshUrl! : props.pageUrl, {
pageNumber: pageInfo.pageNumber,
pageSize: pageInfo.pageSize,
...props.extraQueryParams,
...queryParams.value,
});
if (request.version === pageRequestVersion) {
if (lightweight) {
pageList.value = Array.isArray(res.data)
? res.data
: res.data?.records || [];
emit('loadSuccess', {
lightweight: true,
pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length,
});
return;
}
const rawTotal = Number(res.data?.totalRow || 0);
const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0;
const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize));
@@ -96,6 +126,11 @@ const loadPageListOnce = async (request: PageDataRequest) => {
return;
}
pageList.value = res.data?.records || [];
emit('loadSuccess', {
lightweight: false,
pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length,
});
}
} catch (error) {
if (request.version === pageRequestVersion) {
@@ -104,12 +139,24 @@ const loadPageListOnce = async (request: PageDataRequest) => {
pageList.value = [];
pageInfo.total = 0;
}
emit('loadError', {
error,
lightweight,
pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length,
});
}
}
};
const requestPageList = (silent: boolean) => {
const requestPageList = (silent: boolean, lightweight = false) => {
// 自动刷新只补充最新行;已有请求能够提供同等或更完整的数据时直接复用,
// 避免它使正在进行的分页查询失效或额外排队。
if (activePageRequest && lightweight) {
return activePageRequest;
}
const request: PageDataRequest = {
lightweight,
silent: silent && pageList.value.length > 0,
version: ++pageRequestVersion,
};
@@ -120,6 +167,7 @@ const requestPageList = (silent: boolean) => {
if (activePageRequest) {
pendingPageRequest = pendingPageRequest
? {
lightweight: pendingPageRequest.lightweight && request.lightweight,
silent: pendingPageRequest.silent && request.silent,
version: request.version,
}
@@ -143,10 +191,10 @@ const requestPageList = (silent: boolean) => {
};
// 获取页面数据
const getPageList = () => requestPageList(false);
const getPageList = () => requestPageList(false, false);
const reload = (options: PageDataReloadOptions = {}) =>
requestPageList(Boolean(options.silent));
requestPageList(Boolean(options.silent), Boolean(options.lightweight));
// 分页事件处理
const handleSizeChange = (newSize: number) => {

View File

@@ -19,5 +19,6 @@
"workflow": "Workflow",
"beanMethod": "BeanMethod",
"javaMethod": "JavaMethod",
"example": "example"
"example": "example",
"triggerAccepted": "The job has been submitted"
}

View File

@@ -7,6 +7,37 @@
"jobResult": "JobResult",
"errorInfo": "ErrorInfo",
"status": "Status",
"jobInfo": "Job information",
"triggerSource": "Trigger source",
"timeField": "Fire time type",
"scheduledFireTime": "Scheduled fire time",
"actualFireTime": "Actual fire time",
"attemptCount": "Claim attempts",
"manual": "Manual",
"scheduled": "Scheduled",
"rangeStart": "Start time",
"rangeEnd": "End time",
"autoRefresh": "Auto refresh",
"refreshInterval": "Refresh interval",
"seconds": "s",
"manualRefresh": "Refresh now",
"refreshing": "Refreshing",
"updated": "updated",
"notRefreshed": "Waiting for refresh",
"historyPagePaused": "Paused on history page",
"refreshFailedPaused": "Refresh failed and paused",
"unknownStatus": "Unknown status",
"unknownSource": "Unknown source",
"executionWindow": "Execution window",
"executionSummary": "Execution summary",
"duration": "Duration",
"noExecutionOutput": "No execution output",
"detailTitle": "Execution details",
"executionNode": "Execution node",
"jobOptions": "Job option snapshot",
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Copy failed; select the content manually",
"startTime": "StartTime",
"endTime": "EndTime",
"created": "Created",

View File

@@ -19,5 +19,6 @@
"workflow": "工作流",
"beanMethod": "bean方法",
"javaMethod": "java方法",
"example": "示例"
"example": "示例",
"triggerAccepted": "任务已提交执行"
}

View File

@@ -7,6 +7,37 @@
"jobResult": "执行结果",
"errorInfo": "错误信息",
"status": "执行状态",
"jobInfo": "任务信息",
"triggerSource": "触发来源",
"timeField": "触发时间类型",
"scheduledFireTime": "计划触发时间",
"actualFireTime": "实际触发时间",
"attemptCount": "认领次数",
"manual": "手动",
"scheduled": "计划",
"rangeStart": "开始时间",
"rangeEnd": "结束时间",
"autoRefresh": "自动刷新",
"refreshInterval": "刷新间隔",
"seconds": "秒",
"manualRefresh": "立即刷新",
"refreshing": "正在刷新",
"updated": "已更新",
"notRefreshed": "等待刷新",
"historyPagePaused": "历史页已暂停",
"refreshFailedPaused": "刷新失败,已暂停",
"unknownStatus": "未知状态",
"unknownSource": "未知来源",
"executionWindow": "执行窗口",
"executionSummary": "执行摘要",
"duration": "耗时",
"noExecutionOutput": "暂无执行输出",
"detailTitle": "执行详情",
"executionNode": "执行节点",
"jobOptions": "任务配置快照",
"copy": "复制",
"copied": "已复制",
"copyFailed": "复制失败,请手动选择内容",
"startTime": "开始时间",
"endTime": "结束时间",
"created": "创建时间",

View File

@@ -0,0 +1,42 @@
import { createMemoryHistory, createRouter } from 'vue-router';
import { useAccessStore } from '@easyflow/stores';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import { createRouterGuard } from '../guard';
describe('public route guard', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('bypasses stale login state for an anonymous workflow share', async () => {
const accessStore = useAccessStore();
accessStore.setAccessToken('stale-token');
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
component: { template: '<div>login</div>' },
name: 'Login',
path: '/auth/login',
},
{
component: { template: '<div>workflow share</div>' },
meta: { ignoreAccess: true, title: 'Workflow Share' },
name: 'WorkflowShare',
path: '/share/workflow',
},
],
});
createRouterGuard(router);
await router.push('/share/workflow?shareKey=share-key');
await router.isReady();
expect(router.currentRoute.value.name).toBe('WorkflowShare');
expect(router.currentRoute.value.query.shareKey).toBe('share-key');
});
});

Some files were not shown because too many files have changed in this diff Show More