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 org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; 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.AgentService; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.model.CronJobEntity; import vip.mate.cron.repository.CronJobMapper; import org.springframework.context.ApplicationEventPublisher; import vip.mate.exception.MateClawException; import vip.mate.memory.event.ConversationCompletedEvent; import vip.mate.workspace.conversation.ConversationService; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; 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 AgentService agentService; private final ConversationService conversationService; private final ApplicationEventPublisher eventPublisher; private final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); private final ConcurrentHashMap> scheduledTasks = new ConcurrentHashMap<>(); private final ReentrantLock schedulerLock = new ReentrantLock(); /** 定时任务触发时使用的系统用户标识 */ private static final String SYSTEM_USER = "system"; // ==================== 初始化与销毁 ==================== /** * 实现 ApplicationRunner,确保在 Flyway 迁移和 DatabaseBootstrapRunner 完成后再加载任务 */ @Override public void run(ApplicationArguments args) { scheduler.setPoolSize(4); scheduler.setThreadNamePrefix("cron-job-"); scheduler.initialize(); 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(); } // ==================== CRUD ==================== public List list() { List entities = cronJobMapper.selectList( new LambdaQueryWrapper() .orderByDesc(CronJobEntity::getCreateTime)); // 批量加载 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)); return entities.stream() .map(e -> CronJobDTO.from(e, agentNameMap.getOrDefault(e.getAgentId(), "Unknown"))) .collect(Collectors.toList()); } public CronJobDTO getById(Long id) { CronJobEntity entity = cronJobMapper.selectById(id); if (entity == null) { throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); } AgentEntity agent = agentMapper.selectById(entity.getAgentId()); return CronJobDTO.from(entity, agent != null ? agent.getName() : "Unknown"); } public CronJobDTO create(CronJobDTO dto) { validateDto(dto); // toSpringCron 校验表达式合法性,结果复用于后续 calcNextRunTime 和 register String springCron = toSpringCron(dto.getCronExpression()); CronJobEntity entity = dto.toEntity(); 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())); cronJobMapper.insert(entity); if (Boolean.TRUE.equals(entity.getEnabled())) { // register() 内部会再次调用 toSpringCron,但表达式已校验过,不会抛异常 register(entity); } return getById(entity.getId()); } public CronJobDTO update(Long id, CronJobDTO dto) { CronJobEntity existing = cronJobMapper.selectById(id); if (existing == null) { throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); } validateDto(dto); String springCron = toSpringCron(dto.getCronExpression()); 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())); cronJobMapper.updateById(existing); // 加锁保证 cancel + register 的原子性(ReentrantLock 支持同线程重入) schedulerLock.lock(); try { cancel(id); if (Boolean.TRUE.equals(existing.getEnabled())) { register(existing); } } finally { schedulerLock.unlock(); } return getById(id); } public void delete(Long id) { CronJobEntity entity = cronJobMapper.selectById(id); 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) { CronJobEntity entity = cronJobMapper.selectById(id); 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) { CronJobEntity entity = cronJobMapper.selectById(id); if (entity == null) { throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); } // 异步执行,不阻塞请求线程 scheduler.submit(() -> executeJob(entity)); } // ==================== 调度器管理 ==================== 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); ScheduledFuture future = scheduler.schedule(() -> executeJob(job), trigger); scheduledTasks.put(job.getId(), future); log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(), job.getCronExpression(), job.getTimezone()); } finally { schedulerLock.unlock(); } } private void cancel(Long jobId) { ScheduledFuture f = scheduledTasks.remove(jobId); if (f != null) { f.cancel(false); } } // ==================== 任务执行 ==================== private void executeJob(CronJobEntity job) { String conversationId = "cron:" + job.getId(); try { log.info("[CronJob] Executing job {} ({}), type={}", job.getId(), job.getName(), job.getTaskType()); // 确保会话存在(使用 SYSTEM_USER 作为定时触发的所有者标识,workspace 从 agent 获取) AgentEntity cronAgent = agentMapper.selectById(job.getAgentId()); Long cronWorkspaceId = cronAgent != null ? cronAgent.getWorkspaceId() : 1L; conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER, cronWorkspaceId); String userMessage; String result; if ("agent".equals(job.getTaskType())) { userMessage = job.getRequestBody(); // 保存 user 消息 conversationService.saveMessage(conversationId, "user", userMessage); result = agentService.execute(job.getAgentId(), userMessage, conversationId); } else { userMessage = job.getTriggerMessage(); // 保存 user 消息 conversationService.saveMessage(conversationId, "user", userMessage); result = agentService.chat(job.getAgentId(), userMessage, conversationId); } // 保存 assistant 消息 conversationService.saveMessage(conversationId, "assistant", result); // 发布对话完成事件 try { int msgCount = conversationService.getMessageCount(conversationId); eventPublisher.publishEvent(new ConversationCompletedEvent( job.getAgentId(), conversationId, userMessage, result, msgCount, "cron")); } catch (Exception ex) { log.debug("[Memory] Failed to publish ConversationCompletedEvent: {}", ex.getMessage()); } // 合并更新 lastRunTime + nextRunTime,单次 DB 写入 updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone()); log.info("[CronJob] Job {} executed successfully, result length={}", job.getId(), result != null ? result.length() : 0); } catch (Exception e) { log.error("[CronJob] Job {} execution failed: {}", job.getId(), e.getMessage(), e); } } /** * 合并更新 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"; if ("text".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", "执行目标不能为空"); } } }