feat(app): 初始化 OmniScheduler WPF 调度器
基于 PRD 搭建 .NET 8 WPF 桌面应用,包含主控制台、任务编辑、全局设置、系统托盘和应用图标集成。 实现本地任务模型、触发器规则、JSON 状态持久化、OmniNotify HTTP 推送、执行日志记录、动态变量替换以及基础 Cron 预览能力。 补充 .gitignore,排除构建产物和本地 IDE 文件。 BREAKING CHANGE: 首次提交,建立项目初始结构
This commit is contained in:
449
OmniScheduler/Services.cs
Normal file
449
OmniScheduler/Services.cs
Normal file
@@ -0,0 +1,449 @@
|
||||
using Microsoft.Win32;
|
||||
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
|
||||
{
|
||||
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, "Manual");
|
||||
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" ? "Success" : "Failed";
|
||||
_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?.Kind.ToString() ?? "Scheduled";
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
var start = trigger.StartsAt ?? DateTime.Now;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public static class StartupRegistration
|
||||
{
|
||||
private const string RunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
public static void Apply(bool enabled)
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKey, true);
|
||||
if (key is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
key.SetValue("OmniScheduler", $"\"{Environment.ProcessPath}\" --tray");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue("OmniScheduler", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user