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(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 { 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 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 _logSink; private bool _paused; public SchedulerService(SchedulerState state, NotifyClient client, Action 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 Preview(TaskTrigger trigger, DateTime from, int count = 5) { var results = new List(); 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; } }