mateclaw/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java

671 lines
29 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.cron.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Service;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
import vip.mate.exception.MateClawException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
/**
* 定时任务业务服务
*
* @author MateClaw Team
*/
@Slf4j
@Service
@Order(210)
@RequiredArgsConstructor
public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper;
private final ChannelMapper channelMapper;
/**
* RFC-03 Lane G2: distributed lock for fire-time execution. ShedLock's
* JDBC provider is configured in {@link vip.mate.cron.config.ShedLockConfig}
* — see also {@link #LOCK_AT_MOST_FOR} / {@link #LOCK_AT_LEAST_FOR}
* tuning notes on {@link #register}.
*/
private final LockProvider lockProvider;
/**
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
* (separate bean) so the three-segment transactional model in
* {@link CronJobLifecycleService} works via Spring AOP — no more
* self-invocation footgun.
*
* <p>{@code agentService}, {@code conversationService}, and
* {@code completionPublisher} now live on {@link CronJobLifecycleService}
* and {@link CronJobRunner} so this service shrinks to CRUD + scheduler
* registration only.
*/
private final CronJobRunner cronJobRunner;
private final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private final ReentrantLock schedulerLock = new ReentrantLock();
/**
* RFC-063r post-deploy fix: dedicated executor for the actual cron
* execution work (LLM call + DB writes). The {@link #scheduler} thread
* pool is intentionally tiny — its only job is to fire the trigger and
* hand the runnable off here. If the LLM call ran on the scheduler
* thread, 4 concurrent crons would saturate the pool and queued ones
* would silently miss their tick.
*
* <p>Virtual threads (JDK 21) are perfect for this workload — LLM HTTP
* is I/O-bound, virtual threads scale to thousands at trivial cost.
*/
private final ExecutorService cronExecutor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("cron-execute-", 0).factory());
/**
* Issue #50: cap concurrent cron run executions so that hundreds of jobs
* firing at the same minute boundary cannot exhaust the JDBC pool. Each
* run holds 3-4 connections in sequence (three REQUIRES_NEW segments in
* {@link CronJobLifecycleService} plus {@link #updateRunTimes}); without
* a limit, virtual threads launch unboundedly and starve the channel
* monitor / web traffic. Tuned alongside Hikari maximum-pool-size — keep
* this value well below the pool size so non-cron paths still get
* connections.
*/
private static final int MAX_CONCURRENT_CRON_RUNS = 8;
private final Semaphore cronConcurrencyLimiter = new Semaphore(MAX_CONCURRENT_CRON_RUNS);
/**
* RFC-03 Lane G2: ShedLock duration tuning.
*
* <p>{@code lockAtMostFor} is the safety net for a node that crashes
* mid-execution — after this window, any other node may take over the
* job's next tick. 30 minutes covers all observed cron run times
* (longest LLM-driven jobs in production sit around p99 ≈ 8 min).
*
* <p>{@code lockAtLeastFor} prevents thundering-herd when a fast job
* (e.g. trivial SQL query that completes in <1s) finishes before its
* own next tick — without it, the same node could fire twice per tick
* if its clock is slightly ahead. 30s gives every other node a chance
* to see the lock as held even for instant-finishing jobs.
*/
private static final Duration LOCK_AT_MOST_FOR = Duration.ofMinutes(30);
private static final Duration LOCK_AT_LEAST_FOR = Duration.ofSeconds(30);
// ==================== 初始化与销毁 ====================
/**
* 实现 ApplicationRunner确保在 Flyway 迁移和 DatabaseBootstrapRunner 完成后再加载任务
*/
@Override
public void run(ApplicationArguments args) {
// Pool size = trigger firing parallelism only. Actual execution lives
// on cronExecutor (virtual threads), so 2 is plenty for the trigger
// pump and even 4 was excessive; we keep 4 for headroom.
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("cron-job-");
scheduler.initialize();
// RFC-083: scheduler is process-global by design — load every enabled
// job across all workspaces. Do NOT filter by workspace_id here,
// otherwise jobs in workspace B stop firing whenever the active UI
// workspace is A. Workspace isolation lives in the CRUD paths only.
List<CronJobEntity> enabledJobs = cronJobMapper.selectList(
new LambdaQueryWrapper<CronJobEntity>()
.eq(CronJobEntity::getEnabled, true));
for (CronJobEntity job : enabledJobs) {
try {
register(job);
} catch (Exception e) {
log.warn("[CronJob] Failed to register job {} on startup: {}", job.getId(), e.getMessage());
}
}
log.info("[CronJob] Scheduler initialized, {} jobs registered", enabledJobs.size());
}
@PreDestroy
public void destroy() {
scheduler.shutdown();
cronExecutor.shutdown();
}
// ==================== CRUD ====================
public List<CronJobDTO> list(Long workspaceId) {
// RFC-063r §2.14: use the variant that aggregates the most-recent
// delivery_status from mate_cron_job_run so the list page can
// render the "最近投递" badge without a per-row N+1 query.
// RFC-083: scoped to the caller's workspace.
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus(workspaceId);
// 批量加载 Agent 名称
List<Long> agentIds = entities.stream()
.map(CronJobEntity::getAgentId)
.distinct()
.collect(Collectors.toList());
Map<Long, String> agentNameMap = agentIds.isEmpty() ? Map.of() :
agentMapper.selectBatchIds(agentIds).stream()
.collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName));
// RFC-063r post-deploy fix: surface channel name on the list so the
// UI can show which crons are bound to which IM channel — addresses
// user's "看不到与 channel 有什么关联" complaint.
List<Long> channelIds = entities.stream()
.map(CronJobEntity::getChannelId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, String> channelNameMap = channelIds.isEmpty() ? Map.of() :
channelMapper.selectBatchIds(channelIds).stream()
.collect(Collectors.toMap(ChannelEntity::getId, ChannelEntity::getName));
return entities.stream()
.map(e -> {
CronJobDTO dto = CronJobDTO.from(e, agentNameMap.getOrDefault(e.getAgentId(), "Unknown"));
if (e.getChannelId() != null) {
dto.setChannelName(channelNameMap.get(e.getChannelId()));
}
return dto;
})
.collect(Collectors.toList());
}
public CronJobDTO getById(Long id, Long workspaceId) {
// RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
// subquery shape, restricted to one id.
// RFC-083: scoped to the caller's workspace; cross-workspace ID access
// surfaces as not_found (same shape as deleted) so workspace existence
// is not enumerable.
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id, workspaceId);
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
AgentEntity agent = agentMapper.selectById(entity.getAgentId());
CronJobDTO dto = CronJobDTO.from(entity, agent != null ? agent.getName() : "Unknown");
if (entity.getChannelId() != null) {
ChannelEntity channel = channelMapper.selectById(entity.getChannelId());
if (channel != null) dto.setChannelName(channel.getName());
}
return dto;
}
public CronJobDTO create(CronJobDTO dto, Long workspaceId) {
validateDto(dto);
// toSpringCron 校验表达式合法性,结果复用于后续 calcNextRunTime 和 register
String springCron = toSpringCron(dto.getCronExpression());
// Issue #50: dedup by (workspace_id, agent_id, name). LLM-driven
// creators (CronJobTool) call this on every retry; without this guard
// a single instruction can produce N identical rows that all fire on
// the same tick. App-level check covers the common case; the unique
// index added in V67 protects against races (handled below).
CronJobEntity duplicate = findActiveDuplicate(workspaceId, dto.getAgentId(), dto.getName());
if (duplicate != null) {
log.info("[CronJob] create dedup hit: ws={} agent={} name={} → returning existing id={}",
workspaceId, dto.getAgentId(), dto.getName(), duplicate.getId());
return getById(duplicate.getId(), workspaceId);
}
CronJobEntity entity = dto.toEntity();
// RFC-083: workspace stamped server-side from X-Workspace-Id; never
// trust a client-supplied value (DTO.toEntity intentionally drops it).
entity.setWorkspaceId(workspaceId);
if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai");
if (entity.getTaskType() == null) entity.setTaskType("text");
if (entity.getEnabled() == null) entity.setEnabled(true);
entity.setNextRunTime(calcNextRunTime(springCron, entity.getTimezone()));
try {
cronJobMapper.insert(entity);
} catch (DuplicateKeyException e) {
// Race: another concurrent create won. Re-fetch and return that one.
CronJobEntity raced = findActiveDuplicate(workspaceId, dto.getAgentId(), dto.getName());
if (raced != null) {
log.info("[CronJob] create race resolved: ws={} agent={} name={} → existing id={}",
workspaceId, dto.getAgentId(), dto.getName(), raced.getId());
return getById(raced.getId(), workspaceId);
}
throw e;
}
if (Boolean.TRUE.equals(entity.getEnabled())) {
// register() 内部会再次调用 toSpringCron但表达式已校验过不会抛异常
register(entity);
}
return getById(entity.getId(), workspaceId);
}
/**
* Issue #50: lookup existing active row for the dedup natural key.
* No {@code @TableLogic} on this entity — {@code deleted=0} must be
* filtered explicitly.
*/
/**
* Issue #50 review #6 — excluding-self variant for the update path.
* Same lookup as {@link #findActiveDuplicate} but skips the row
* currently being edited so a no-op save (same name, same agent)
* doesn't false-positive as a duplicate.
*/
private CronJobEntity findActiveDuplicateExcluding(Long workspaceId, Long agentId, String name, Long excludeId) {
if (workspaceId == null || agentId == null || name == null) return null;
LambdaQueryWrapper<CronJobEntity> q = new LambdaQueryWrapper<CronJobEntity>()
.eq(CronJobEntity::getWorkspaceId, workspaceId)
.eq(CronJobEntity::getAgentId, agentId)
.eq(CronJobEntity::getName, name)
.eq(CronJobEntity::getDeleted, 0);
if (excludeId != null) q.ne(CronJobEntity::getId, excludeId);
return cronJobMapper.selectOne(q);
}
private CronJobEntity findActiveDuplicate(Long workspaceId, Long agentId, String name) {
if (workspaceId == null || agentId == null || name == null) return null;
return cronJobMapper.selectOne(
new LambdaQueryWrapper<CronJobEntity>()
.eq(CronJobEntity::getWorkspaceId, workspaceId)
.eq(CronJobEntity::getAgentId, agentId)
.eq(CronJobEntity::getName, name)
.eq(CronJobEntity::getDeleted, 0)
.last("LIMIT 1"));
}
public CronJobDTO update(Long id, CronJobDTO dto, Long workspaceId) {
// RFC-083: scoped lookup — cross-workspace updates 404 the same as
// deleted rows.
CronJobEntity existing = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (existing == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
validateDto(dto);
String springCron = toSpringCron(dto.getCronExpression());
// Issue #50 review #6: excluding-self duplicate check. Without
// this, renaming a job onto an existing (workspace, agent, name)
// tuple surfaces as a raw DataIntegrityViolationException from
// the V69 unique index instead of a controlled validation
// error. Try app-level check first; the catch below is the
// race-protection net.
Long newAgentId = dto.getAgentId();
String newName = dto.getName();
if (newName != null && newAgentId != null) {
CronJobEntity collision = findActiveDuplicateExcluding(workspaceId, newAgentId, newName, id);
if (collision != null) {
throw new MateClawException("err.cron.duplicate_name",
"已存在同名定时任务: name=" + newName + ", agentId=" + newAgentId);
}
}
existing.setName(dto.getName());
existing.setCronExpression(dto.getCronExpression());
existing.setTimezone(dto.getTimezone() != null ? dto.getTimezone() : "Asia/Shanghai");
existing.setAgentId(dto.getAgentId());
existing.setTaskType(dto.getTaskType());
existing.setTriggerMessage(dto.getTriggerMessage());
existing.setRequestBody(dto.getRequestBody());
if (dto.getEnabled() != null) {
existing.setEnabled(dto.getEnabled());
}
existing.setNextRunTime(calcNextRunTime(springCron, existing.getTimezone()));
try {
cronJobMapper.updateById(existing);
} catch (DuplicateKeyException e) {
// Race: another concurrent rename grabbed the natural key
// between our app-level check and the UPDATE. Translate to
// a clean validation error so the controller can 4xx instead
// of a 500 leaking the unique-key constraint name.
throw new MateClawException("err.cron.duplicate_name",
"已存在同名定时任务: name=" + dto.getName() + ", agentId=" + dto.getAgentId());
}
// 加锁保证 cancel + register 的原子性ReentrantLock 支持同线程重入)
schedulerLock.lock();
try {
cancel(id);
if (Boolean.TRUE.equals(existing.getEnabled())) {
register(existing);
}
} finally {
schedulerLock.unlock();
}
return getById(id, workspaceId);
}
public void delete(Long id, Long workspaceId) {
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
schedulerLock.lock();
try {
cancel(id);
} finally {
schedulerLock.unlock();
}
cronJobMapper.deleteById(id);
}
public void toggle(Long id, Boolean enabled, Long workspaceId) {
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
entity.setEnabled(enabled);
// 先更新 DB再同步调度器避免调度器已注册但 DB 未持久化的不一致状态
if (Boolean.TRUE.equals(enabled)) {
String springCron = toSpringCron(entity.getCronExpression());
entity.setNextRunTime(calcNextRunTime(springCron, entity.getTimezone()));
} else {
entity.setNextRunTime(null);
}
cronJobMapper.updateById(entity);
// 加锁保证 cancel + register 的原子性
schedulerLock.lock();
try {
cancel(id);
if (Boolean.TRUE.equals(enabled)) {
register(entity);
}
} finally {
schedulerLock.unlock();
}
}
public void runNow(Long id, Long workspaceId) {
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
// RFC-063r §2.7.1: delegate to CronJobRunner via Spring proxy so
// the three-segment REQUIRES_NEW transactions on
// CronJobLifecycleService work as advertised. "manual" trigger type
// distinguishes this from scheduler-driven runs in mate_cron_job_run.
// Run on the virtual-thread cronExecutor — never block the scheduler.
cronExecutor.submit(() -> runWithBackpressure(entity, "manual"));
}
// ==================== 调度器管理 ====================
private void register(CronJobEntity job) {
schedulerLock.lock();
try {
cancel(job.getId());
String springCron = toSpringCron(job.getCronExpression());
ZoneId zoneId = ZoneId.of(job.getTimezone());
CronTrigger trigger = new CronTrigger(springCron, zoneId);
// RFC-063r §2.7.1 + post-deploy fix: scheduler thread fires the
// trigger and immediately offloads to the virtual-thread
// cronExecutor — the LLM call must NOT run on a scheduler
// worker (4 concurrent long crons would otherwise saturate the
// pool and the 5th would miss its tick).
//
// RFC-03 Lane G2: tickWithDistributedLock wraps the offload in
// a ShedLock acquire/release so a multi-instance deployment
// fires each tick exactly once. See javadoc on that method.
ScheduledFuture<?> future = scheduler.schedule(
() -> tickWithDistributedLock(job), trigger);
scheduledTasks.put(job.getId(), future);
log.info("[CronJob] Registered job {} ({}) ws={}, cron={}, tz={}",
job.getId(), job.getName(), job.getWorkspaceId(),
job.getCronExpression(), job.getTimezone());
} finally {
schedulerLock.unlock();
}
}
private void cancel(Long jobId) {
ScheduledFuture<?> f = scheduledTasks.remove(jobId);
if (f != null) {
f.cancel(false);
}
}
/**
* RFC-03 Lane G2 — distributed-lock-guarded tick fire.
*
* <p>Called on the scheduler thread when {@link CronTrigger} fires.
* Tries to acquire a ShedLock entry keyed by {@code "cron-job-{jobId}"};
* if another node holds it, returns immediately (silent skip — siblings
* always see this for every tick, which is by design). On success,
* passes lock ownership into the virtual-thread executor so the work
* proceeds on {@link #cronExecutor} and the lock releases only after
* {@code runWithBackpressure} completes. {@link #LOCK_AT_MOST_FOR} is
* the safety net for a node that crashes mid-execution.
*
* <p>Package-private so unit tests can drive lock-acquisition outcomes
* without booting the full Spring context.
*/
void tickWithDistributedLock(CronJobEntity job) {
Long jobId = job.getId();
Optional<SimpleLock> maybeLock = lockProvider.lock(new LockConfiguration(
Instant.now(),
"cron-job-" + jobId,
LOCK_AT_MOST_FOR,
LOCK_AT_LEAST_FOR));
if (maybeLock.isEmpty()) {
log.debug("[CronJob] {} skipped — another node holds the tick lock", jobId);
return;
}
SimpleLock lock = maybeLock.get();
cronExecutor.submit(() -> {
try {
runWithBackpressure(job, "scheduled");
} finally {
try {
lock.unlock();
} catch (Exception unlockEx) {
// Lock will expire after LOCK_AT_MOST_FOR anyway; log + continue
// so a transient unlock failure doesn't taint the cron worker.
log.warn("[CronJob] {} lock release failed: {}", jobId, unlockEx.getMessage());
}
}
});
}
// ==================== 任务执行 ====================
//
// RFC-063r §2.7.1: the executeJob body moved to CronJobRunner so the
// three-segment transactional model in CronJobLifecycleService runs
// through a Spring AOP proxy. CronJobService now only owns CRUD +
// scheduler registration, and the lastRunTime / nextRunTime bookkeeping
// hook below — which deliberately runs *after* the runner returns so a
// failed run still advances the next-run pointer (otherwise a single
// bad run wedges all future ticks).
//
// Both register() and runNow() now delegate to cronJobRunner.executeJob;
// see those methods above. The wrap below ensures next-run rolls forward
// regardless of run outcome.
/**
* Issue #50: gate every run on {@link #cronConcurrencyLimiter} so that a
* minute-boundary stampede of N enabled jobs cannot fan out into N
* simultaneous JDBC connection acquisitions. Excess runs queue on the
* virtual thread (cheap) instead of competing for the pool.
*
* <p>The {@code updateRunTimes} write is intentionally inside the
* permit's hold so the next-run pointer advances under the same
* backpressure budget — otherwise a tail of bookkeeping writes could
* still pile up after the executor "finishes".
*/
private void runWithBackpressure(CronJobEntity job, String triggerType) {
try {
cronConcurrencyLimiter.acquire();
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("[CronJob] Interrupted while waiting to run job {} ({})",
job.getId(), triggerType);
return;
}
try {
cronJobRunner.executeJob(job, triggerType);
} finally {
try {
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
} finally {
cronConcurrencyLimiter.release();
}
}
}
/**
* 合并更新 lastRunTime 和 nextRunTime单次 DB 写入替代原来的 4 次 selectById + updateById
*/
private void updateRunTimes(Long jobId, String cronExpression, String timezone) {
try {
String springCron = toSpringCron(cronExpression);
LocalDateTime nextRun = calcNextRunTime(springCron, timezone);
cronJobMapper.update(null, new LambdaUpdateWrapper<CronJobEntity>()
.eq(CronJobEntity::getId, jobId)
.set(CronJobEntity::getLastRunTime, LocalDateTime.now())
.set(CronJobEntity::getNextRunTime, nextRun));
} catch (Exception e) {
log.warn("[CronJob] Failed to update run times for job {}: {}", jobId, e.getMessage());
}
}
// ==================== Cron 工具方法 ====================
/**
* 5 字段用户 cron → 6 字段 Spring cron
*/
private String toSpringCron(String cron) {
String[] parts = cron.trim().split("\\s+");
if (parts.length != 5) {
throw new MateClawException("Cron 表达式必须是 5 字段(分 时 日 月 周)");
}
// 标准化 day-of-week
parts[4] = normalizeDayOfWeek(parts[4]);
String springCron = "0 " + String.join(" ", parts);
try {
CronExpression.parse(springCron);
} catch (IllegalArgumentException e) {
throw new MateClawException("Cron 表达式非法: " + e.getMessage());
}
return springCron;
}
/**
* 标准化 day-of-week 字段:将独立的 7Sunday归一化为 0
* 支持单值、列表、范围、步长格式
*/
private String normalizeDayOfWeek(String dow) {
// 处理逗号分隔的列表
String[] tokens = dow.split(",");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < tokens.length; i++) {
if (i > 0) sb.append(",");
sb.append(normalizeToken(tokens[i]));
}
return sb.toString();
}
private String normalizeToken(String token) {
// 处理步长:如 1-7/2 或 */2
int slashIdx = token.indexOf('/');
if (slashIdx >= 0) {
String base = token.substring(0, slashIdx);
String step = token.substring(slashIdx + 1);
return normalizeRangeOrValue(base) + "/" + step;
}
// 处理范围:如 1-5
int dashIdx = token.indexOf('-');
if (dashIdx >= 0) {
return normalizeRangeOrValue(token);
}
// 单值
return normalizeSingleValue(token);
}
private String normalizeRangeOrValue(String expr) {
int dashIdx = expr.indexOf('-');
if (dashIdx >= 0) {
String start = normalizeSingleValue(expr.substring(0, dashIdx));
String end = normalizeSingleValue(expr.substring(dashIdx + 1));
return start + "-" + end;
}
return normalizeSingleValue(expr);
}
private String normalizeSingleValue(String val) {
if ("7".equals(val.trim())) {
return "0";
}
return val;
}
/**
* 计算下次执行时间
*/
private LocalDateTime calcNextRunTime(String springCron, String timezone) {
try {
CronExpression cronExpression = CronExpression.parse(springCron);
ZoneId zoneId = ZoneId.of(timezone);
ZonedDateTime next = cronExpression.next(ZonedDateTime.now(zoneId));
if (next != null) {
return next.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime();
}
} catch (Exception e) {
log.warn("[CronJob] Failed to calculate next run time: {}", e.getMessage());
}
return null;
}
// ==================== 校验 ====================
private void validateDto(CronJobDTO dto) {
if (dto.getName() == null || dto.getName().isBlank()) {
throw new MateClawException("err.cron.name_required", "任务名称不能为空");
}
if (dto.getAgentId() == null) {
throw new MateClawException("err.cron.agent_required", "请选择关联 Agent");
}
if (dto.getCronExpression() == null || dto.getCronExpression().isBlank()) {
throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空");
}
String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text";
// 'text' (LLM chat) and 'reminder' (direct push) both rely on triggerMessage.
if (("text".equals(taskType) || "reminder".equals(taskType))
&& (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) {
throw new MateClawException("err.cron.trigger_required", "触发消息不能为空");
}
if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) {
throw new MateClawException("err.cron.target_required", "执行目标不能为空");
}
}
}