Files
omni-scheduler/OmniScheduler/Services.cs
gamewhale d33fe30569 feat: 优化触发器配置体验
- 根据触发器类型动态展示对应配置区域,减少无关字段干扰。
- 将单次、每日、每周、每月和生效时间范围改为日期选择器与时分秒下拉选择,避免手动输入时间格式。
- 为单次执行增加延后执行快捷设置,支持常用快捷按钮和自定义分钟、小时、天后执行。
- 移除开机自启设置、注册表写入逻辑和相关配置字段,降低对用户系统的影响。
- 同步优化部分任务状态、触发摘要和设置界面文案。
2026-05-21 10:54:20 +08:00

432 lines
14 KiB
C#
Raw Permalink 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.
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Windows.Threading;
namespace OmniScheduler;
public sealed class StateStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly string _statePath;
public StateStore()
{
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniScheduler");
Directory.CreateDirectory(dir);
_statePath = Path.Combine(dir, "state.json");
}
public SchedulerState Load()
{
if (!File.Exists(_statePath))
{
return Seed();
}
try
{
var state = JsonSerializer.Deserialize<SchedulerState>(File.ReadAllText(_statePath), JsonOptions);
return state ?? Seed();
}
catch
{
return Seed();
}
}
public void Save(SchedulerState state)
{
TrimLogs(state);
File.WriteAllText(_statePath, JsonSerializer.Serialize(state, JsonOptions), Encoding.UTF8);
}
private static SchedulerState Seed()
{
var task = new ScheduledTask
{
IsEnabled = false,
Name = "OmniNotify 连通性测试",
Description = "可克隆此任务作为新消息模板。默认禁用,避免首次启动自动发送。",
Channel = "default",
Title = "OmniScheduler 测试",
Body = "来自 {TaskName} 的测试消息,触发时间 {CurrentTime}。"
};
task.Triggers.Add(new TaskTrigger { Kind = TriggerKind.Interval, IntervalValue = 30, IntervalUnit = IntervalUnit.Minutes });
return new SchedulerState { Tasks = new ObservableCollection<ScheduledTask> { task } };
}
private static void TrimLogs(SchedulerState state)
{
var cutoff = DateTime.Now.AddDays(-state.Settings.LogRetentionDays);
var kept = state.Logs
.Where(l => l.Timestamp >= cutoff)
.OrderByDescending(l => l.Timestamp)
.Take(state.Settings.MaxLogRecords)
.OrderByDescending(l => l.Timestamp)
.ToList();
state.Logs.Clear();
foreach (var log in kept)
{
state.Logs.Add(log);
}
}
}
public sealed class NotifyClient
{
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
public async Task<ExecutionLog> SendAsync(ScheduledTask task, string triggerType, string targetUrl, CancellationToken cancellationToken = default)
{
var payload = new
{
channel = Expand(task.Channel, task, triggerType),
title = Expand(task.Title, task, triggerType),
body = Expand(task.Body, task, triggerType)
};
var json = JsonSerializer.Serialize(payload);
var sw = Stopwatch.StartNew();
var log = new ExecutionLog
{
Timestamp = DateTime.Now,
TaskName = task.Name,
TriggerType = triggerType,
TargetUrl = targetUrl,
RequestJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })
};
try
{
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _http.PostAsync(targetUrl, content, cancellationToken);
log.StatusCode = (int)response.StatusCode;
log.Response = await response.Content.ReadAsStringAsync(cancellationToken);
log.Level = response.IsSuccessStatusCode ? "INFO" : "ERROR";
log.Message = response.IsSuccessStatusCode ? "发送成功" : BuildErrorMessage(log.Response, response.StatusCode.ToString());
}
catch (Exception ex)
{
log.Level = "ERROR";
log.Response = ex.Message;
log.Message = ex.Message;
}
finally
{
sw.Stop();
log.ElapsedMs = sw.ElapsedMilliseconds;
}
return log;
}
private static string Expand(string value, ScheduledTask task, string triggerType)
=> value
.Replace("{CurrentTime}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), StringComparison.OrdinalIgnoreCase)
.Replace("{TaskName}", task.Name, StringComparison.OrdinalIgnoreCase)
.Replace("{TriggerType}", triggerType, StringComparison.OrdinalIgnoreCase);
private static string BuildErrorMessage(string response, string fallback)
=> response.Contains("IllegalChannel", StringComparison.OrdinalIgnoreCase)
? "ERROR: IllegalChannel请检查频道名配置"
: $"发送失败 {fallback}";
}
public sealed class SchedulerService
{
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly SchedulerState _state;
private readonly NotifyClient _client;
private readonly Action<ExecutionLog> _logSink;
private bool _paused;
public SchedulerService(SchedulerState state, NotifyClient client, Action<ExecutionLog> logSink)
{
_state = state;
_client = client;
_logSink = logSink;
_timer.Tick += async (_, _) => await TickAsync();
}
public bool IsPaused
{
get => _paused;
set => _paused = value;
}
public void Start()
{
RecalculateNextRuns();
_timer.Start();
}
public void RecalculateNextRuns()
{
foreach (var task in _state.Tasks)
{
task.NextRunAt = task.IsEnabled ? FindNext(task, DateTime.Now) : null;
task.Notify(nameof(ScheduledTask.TriggerSummary));
}
}
public async Task FireNowAsync(ScheduledTask task)
{
await ExecuteAsync(task, "手动触发");
task.NextRunAt = FindNext(task, DateTime.Now);
}
private async Task TickAsync()
{
if (_paused)
{
return;
}
var now = DateTime.Now;
var due = _state.Tasks
.Where(t => t.IsEnabled && t.NextRunAt.HasValue && t.NextRunAt.Value <= now)
.ToList();
await Task.WhenAll(due.Select(async task =>
{
var scheduled = task.NextRunAt!.Value;
if (now - scheduled > TimeSpan.FromSeconds(30) && task.MisfireStrategy == MisfireStrategy.DoNothing)
{
task.NextRunAt = FindNext(task, now);
return;
}
await ExecuteAsync(task, PickTriggerSummary(task, scheduled));
task.NextRunAt = FindNext(task, now.AddSeconds(1));
}));
}
private async Task ExecuteAsync(ScheduledTask task, string triggerType)
{
task.LastRunAt = DateTime.Now;
var log = await _client.SendAsync(task, triggerType, _state.Settings.OmniNotifyUrl);
task.LastStatus = log.Level == "INFO" ? "成功" : "失败";
_logSink(log);
}
private static string PickTriggerSummary(ScheduledTask task, DateTime dueAt)
{
var trigger = task.Triggers
.OrderBy(t => Math.Abs(((NextRunCalculator.NextRun(t, DateTime.Now.AddDays(-1)) ?? dueAt) - dueAt).TotalSeconds))
.FirstOrDefault();
return trigger?.Summary ?? "计划触发";
}
private static DateTime? FindNext(ScheduledTask task, DateTime from)
{
return task.Triggers
.Select(t => NextRunCalculator.NextRun(t, from))
.Where(d => d.HasValue)
.Min();
}
}
public static class NextRunCalculator
{
public static DateTime? NextRun(TaskTrigger trigger, DateTime from)
{
if (trigger.EndsAt.HasValue && from > trigger.EndsAt.Value)
{
return null;
}
var earliest = trigger.StartsAt.HasValue && trigger.StartsAt.Value > from ? trigger.StartsAt.Value : from;
DateTime? next = trigger.Kind switch
{
TriggerKind.OneTime => trigger.OneTimeAt > from ? trigger.OneTimeAt : null,
TriggerKind.Interval => NextInterval(trigger, earliest),
TriggerKind.Daily => NextDaily(trigger.TimeOfDay, earliest),
TriggerKind.Weekly => NextWeekly(trigger, earliest),
TriggerKind.Monthly => NextMonthly(trigger, earliest),
TriggerKind.Cron => NextCron(trigger.CronExpression, earliest),
_ => null
};
return trigger.EndsAt.HasValue && next > trigger.EndsAt.Value ? null : next;
}
public static IReadOnlyList<DateTime> Preview(TaskTrigger trigger, DateTime from, int count = 5)
{
var results = new List<DateTime>();
var cursor = from;
for (var i = 0; i < count; i++)
{
var next = NextRun(trigger, cursor);
if (!next.HasValue)
{
break;
}
results.Add(next.Value);
cursor = next.Value.AddSeconds(1);
}
return results;
}
private static DateTime? NextInterval(TaskTrigger trigger, DateTime from)
{
if (trigger.IntervalValue < 1)
{
return null;
}
var interval = trigger.IntervalUnit switch
{
IntervalUnit.Seconds => TimeSpan.FromSeconds(trigger.IntervalValue),
IntervalUnit.Minutes => TimeSpan.FromMinutes(trigger.IntervalValue),
IntervalUnit.Hours => TimeSpan.FromHours(trigger.IntervalValue),
IntervalUnit.Days => TimeSpan.FromDays(trigger.IntervalValue),
_ => TimeSpan.FromMinutes(trigger.IntervalValue)
};
if (!trigger.StartsAt.HasValue)
{
return from.Add(interval);
}
var start = trigger.StartsAt.Value;
if (from <= start)
{
return start;
}
var steps = Math.Ceiling((from - start).TotalSeconds / interval.TotalSeconds);
return start.AddSeconds(steps * interval.TotalSeconds);
}
private static DateTime NextDaily(TimeSpan time, DateTime from)
{
var candidate = from.Date.Add(time);
return candidate > from ? candidate : candidate.AddDays(1);
}
private static DateTime NextWeekly(TaskTrigger trigger, DateTime from)
{
for (var i = 0; i < 14; i++)
{
var day = from.Date.AddDays(i);
if (HasDay(trigger.WeekDays, day.DayOfWeek))
{
var candidate = day.Add(trigger.TimeOfDay);
if (candidate > from)
{
return candidate;
}
}
}
return from.AddDays(7);
}
private static DateTime? NextMonthly(TaskTrigger trigger, DateTime from)
{
for (var i = 0; i < 24; i++)
{
var month = new DateTime(from.Year, from.Month, 1).AddMonths(i);
var day = trigger.LastBusinessDay
? LastBusinessDay(month.Year, month.Month)
: Math.Min(Math.Max(trigger.MonthDay, 1), DateTime.DaysInMonth(month.Year, month.Month));
var candidate = new DateTime(month.Year, month.Month, day).Add(trigger.TimeOfDay);
if (candidate > from)
{
return candidate;
}
}
return null;
}
private static DateTime? NextCron(string expression, DateTime from)
{
var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 6)
{
return null;
}
for (var probe = from.AddSeconds(1); probe < from.AddDays(366); probe = probe.AddSeconds(1))
{
if (Matches(parts[0], probe.Second, 0, 59) &&
Matches(parts[1], probe.Minute, 0, 59) &&
Matches(parts[2], probe.Hour, 0, 23) &&
Matches(parts[3], probe.Day, 1, 31) &&
Matches(parts[4], probe.Month, 1, 12) &&
(parts[5] == "?" || Matches(parts[5], (int)probe.DayOfWeek, 0, 6)))
{
return probe;
}
}
return null;
}
private static bool Matches(string field, int value, int min, int max)
{
if (field is "*" or "?")
{
return true;
}
return field.Split(',').Any(part =>
{
if (part.Contains('/'))
{
var pieces = part.Split('/');
var step = int.TryParse(pieces[1], out var parsedStep) ? parsedStep : 1;
var start = pieces[0] == "*" ? min : int.TryParse(pieces[0], out var parsedStart) ? parsedStart : min;
return value >= start && (value - start) % step == 0;
}
if (part.Contains('-'))
{
var range = part.Split('-');
return int.TryParse(range[0], out var a) && int.TryParse(range[1], out var b) && value >= a && value <= b;
}
return int.TryParse(part, out var exact) && exact == value;
});
}
private static bool HasDay(DayOfWeekFlags flags, DayOfWeek day)
{
var flag = day switch
{
DayOfWeek.Sunday => DayOfWeekFlags.Sunday,
DayOfWeek.Monday => DayOfWeekFlags.Monday,
DayOfWeek.Tuesday => DayOfWeekFlags.Tuesday,
DayOfWeek.Wednesday => DayOfWeekFlags.Wednesday,
DayOfWeek.Thursday => DayOfWeekFlags.Thursday,
DayOfWeek.Friday => DayOfWeekFlags.Friday,
DayOfWeek.Saturday => DayOfWeekFlags.Saturday,
_ => DayOfWeekFlags.None
};
return flags.HasFlag(flag);
}
private static int LastBusinessDay(int year, int month)
{
var date = new DateTime(year, month, DateTime.DaysInMonth(year, month));
while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
{
date = date.AddDays(-1);
}
return date.Day;
}
}