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.
*
*
{@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> 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.
*
* 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.
*
*
{@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).
*
*
{@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 enabledJobs = cronJobMapper.selectList(
new LambdaQueryWrapper()
.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 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 entities = cronJobMapper.selectListWithDeliveryStatus(workspaceId);
// 批量加载 Agent 名称
List agentIds = entities.stream()
.map(CronJobEntity::getAgentId)
.distinct()
.collect(Collectors.toList());
Map 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 channelIds = entities.stream()
.map(CronJobEntity::getChannelId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map 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 q = new LambdaQueryWrapper()
.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()
.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.
*
* 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.
*
*
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 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.
*
* 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()
.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 字段:将独立的 7(Sunday)归一化为 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", "执行目标不能为空");
}
}
}