Files
ThingsGateway/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIMiddleware.cs
2248356998 qq.com 3c9f97a5c3 10.11.24
2025-09-04 22:39:21 +08:00

382 lines
16 KiB
C#
Raw 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.

// ------------------------------------------------------------------------
// 版权信息
// 版权归百小僧及百签科技(广东)有限公司所有。
// 所有权利保留。
// 官方网站https://baiqian.com
//
// 许可证信息
// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。
// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。
// ------------------------------------------------------------------------
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ThingsGateway.Schedule;
/// <summary>
/// Schedule 模块 UI 中间件
/// </summary>
[SuppressSniffer]
public sealed class ScheduleUIMiddleware
{
private const string STATIC_FILES_PATH = "/__schedule__";
/// <summary>
/// 请求委托
/// </summary>
private readonly RequestDelegate _next;
/// <summary>
/// 作业计划工厂
/// </summary>
private readonly ISchedulerFactory _schedulerFactory;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="next">请求委托</param>
/// <param name="schedulerFactory">作业计划工厂</param>
/// <param name="options">UI 配置选项</param>
public ScheduleUIMiddleware(RequestDelegate next
, ISchedulerFactory schedulerFactory
, ScheduleUIOptions options)
{
_next = next;
_schedulerFactory = schedulerFactory;
Options = options;
ApiRequestPath = $"{options.RequestPath}/api";
}
/// <summary>
/// UI 配置选项
/// </summary>
public ScheduleUIOptions Options { get; }
/// <summary>
/// API 入口地址
/// </summary>
public string ApiRequestPath { get; }
/// <summary>
/// 中间件执行方法
/// </summary>
/// <param name="context"><see cref="HttpContext"/></param>
/// <returns><see cref="Task"/></returns>
public async Task InvokeAsync(HttpContext context)
{
// 非看板请求跳过
if (!context.Request.Path.StartsWithSegments(Options.RequestPath, StringComparison.OrdinalIgnoreCase))
{
await _next(context).ConfigureAwait(false);
return;
}
// ================================ 处理静态文件请求 ================================
var staticFilePath = Options.RequestPath + "/";
if (context.Request.Path.Equals(staticFilePath, StringComparison.OrdinalIgnoreCase) || context.Request.Path.Equals(staticFilePath + "apiconfig.js", StringComparison.OrdinalIgnoreCase))
{
var targetPath = context.Request.Path.Value?[staticFilePath.Length..];
var isIndex = string.IsNullOrEmpty(targetPath);
// 获取当前类型所在程序集和对应嵌入式文件路径
var currentAssembly = typeof(ScheduleUIExtensions).Assembly;
// 读取配置文件内容
byte[] buffer;
using (var readStream = currentAssembly.GetManifestResourceStream($"{currentAssembly.GetName().Name}.Schedule.Dashboard.frontend.{(isIndex ? "index.html" : targetPath)}"))
{
buffer = new byte[readStream.Length];
_ = await readStream.ReadAsync(buffer).ConfigureAwait(false);
}
// 替换配置占位符
string content;
using (var stream = new MemoryStream(buffer))
{
using var streamReader = new StreamReader(stream, new UTF8Encoding(false));
content = await streamReader.ReadToEndAsync().ConfigureAwait(false);
content = isIndex
? content.Replace(STATIC_FILES_PATH, $"{Options.VirtualPath}{Options.RequestPath}")
: content.Replace("%(RequestPath)", $"{Options.VirtualPath}{Options.RequestPath}")
.Replace("%(DisplayEmptyTriggerJobs)", Options.DisplayEmptyTriggerJobs ? "true" : "false")
.Replace("%(DisplayHead)", Options.DisplayHead ? "true" : "false")
.Replace("%(DefaultExpandAllJobs)", Options.DefaultExpandAllJobs ? "true" : "false")
.Replace("%(UseUtcTimestamp)", ScheduleOptionsBuilder.UseUtcTimestampProperty ? "true" : "false")
.Replace("%(Title)", Options.Title ?? string.Empty)
.Replace("%(Login.SessionKey)", Options.LoginConfig?.SessionKey ?? "schedule_session_key")
.Replace("%(Login.DefaultUsername)", Options.LoginConfig?.DefaultUsername ?? string.Empty)
.Replace("%(Login.DefaultPassword)", Options.LoginConfig?.DefaultPassword ?? string.Empty);
}
// 输出到客户端
context.Response.ContentType = $"text/{(isIndex ? "html" : "javascript")}; charset=utf-8";
await context.Response.WriteAsync(content).ConfigureAwait(false);
return;
}
// 处理刷新登录页面出现 404 情况
if (context.Request.Path.Equals(staticFilePath + "login", StringComparison.OrdinalIgnoreCase))
{
context.Response.Redirect(staticFilePath);
return;
}
// ================================ 处理 API 请求 ================================
// 如果不是以 API_REQUEST_PATH 开头,则跳过
if (!context.Request.Path.StartsWithSegments(ApiRequestPath, StringComparison.OrdinalIgnoreCase))
{
await _next(context).ConfigureAwait(false);
return;
}
// 只处理 GET/POST 请求
if (!context.Request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && !context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
{
await _next(context).ConfigureAwait(false);
return;
}
// 获取匹配的路由标识
var action = context.Request.Path.Value?[ApiRequestPath.Length..]?.ToLower();
// 允许跨域,设置返回 json
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.Headers.AccessControlAllowOrigin = "*";
context.Response.Headers.AccessControlAllowHeaders = "*";
// 路由匹配
switch (action)
{
// 获取所有作业
case "/get-jobs":
var jobs = _schedulerFactory.GetJobsOfModels().OrderBy(u => u.JobDetail.GroupName).ThenBy(u => u.JobDetail.JobId);
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(jobs)).ConfigureAwait(false);
break;
// 获取所有运行记录
case "/timelines-log":
var allTimelines = _schedulerFactory.GetJobs()
.SelectMany(u => u.GetTriggers().SelectMany(s => s.GetTimelines()))
.OrderByDescending(u => u.CreatedTime)
.Take(20); // 默认取 20 条
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(allTimelines)).ConfigureAwait(false);
break;
// 操作作业
case "/operate-job":
// 获取作业 Id
var jobId = context.Request.Query["jobid"];
// 获取操作方法
var operate = context.Request.Query["action"];
// 获取作业计划
var scheduleResult = _schedulerFactory.TryGetJob(jobId, out var scheduler);
// 处理找不到作业情况
if (scheduleResult != ScheduleResult.Succeed)
{
// 标识状态码为 500
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(new
{
msg = scheduleResult.ToString(),
ok = false
})).ConfigureAwait(false);
return;
}
switch (operate)
{
// 启动作业
case "start":
scheduler?.Start();
break;
// 暂停作业
case "pause":
scheduler?.Pause();
break;
// 移除作业
case "remove":
_schedulerFactory.RemoveJob(jobId);
break;
// 手动执行
case "run":
_schedulerFactory.RunJob(jobId);
break;
}
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(new
{
msg = ScheduleResult.Succeed.ToString(),
ok = true
})).ConfigureAwait(false);
break;
// 操作触发器
case "/operate-trigger":
// 获取作业 Id
var jobId1 = context.Request.Query["jobid"];
var triggerId = context.Request.Query["triggerid"];
// 获取操作方法
var operate1 = context.Request.Query["action"];
// 获取作业计划
var scheduleResult1 = _schedulerFactory.TryGetJob(jobId1, out var scheduler1);
// 处理找不到作业情况
if (scheduleResult1 != ScheduleResult.Succeed)
{
// 标识状态码为 500
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(new
{
msg = scheduleResult1.ToString(),
ok = false
})).ConfigureAwait(false);
return;
}
switch (operate1)
{
// 启动作业触发器
case "start":
scheduler1?.StartTrigger(triggerId);
break;
// 暂停作业触发器
case "pause":
scheduler1?.PauseTrigger(triggerId);
break;
// 移除作业触发器
case "remove":
scheduler1?.RemoveTrigger(triggerId);
break;
// 手动执行
case "run":
scheduler1?.Run(triggerId);
break;
// 获取作业触发器最近运行时间
case "timelines":
var trigger = scheduler1?.GetTrigger(triggerId);
var timelines = trigger?.GetTimelines() ?? Array.Empty<TriggerTimeline>();
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(timelines)).ConfigureAwait(false);
return;
}
// 输出 JSON
await context.Response.WriteAsync(SerializeToJson(new
{
msg = ScheduleResult.Succeed.ToString(),
ok = true
})).ConfigureAwait(false);
break;
// 推送更新
case "/check-change":
// 检查请求类型,是否为 text/event-stream 格式
if (!context.WebSockets.IsWebSocketRequest && context.Request.Headers.Accept.ToString().Contains("text/event-stream"))
{
// 设置响应头的 content-type 为 text/event-stream
context.Response.ContentType = "text/event-stream";
// 设置响应头,启用响应发送保持活动性
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
// 防止 Nginx 缓存 Server-Sent Events
context.Response.Headers["X-Accel-Buffering"] = "no";
var queue = new BlockingCollection<JobDetail>();
// 监听作业计划变化
void Subscribe(object sender, SchedulerEventArgs args)
{
if (!queue.IsAddingCompleted)
{
queue.Add(args.JobDetail);
}
}
_schedulerFactory.OnChanged += Subscribe;
// 持续发送 SSE 协议数据
foreach (var jobDetail in queue.GetConsumingEnumerable())
{
// 如果请求已终止则停止推送
if (!context.RequestAborted.IsCancellationRequested)
{
var message = "data: " + SerializeToJson(jobDetail) + "\n\n";
await context.Response.WriteAsync(message, context.RequestAborted).ConfigureAwait(false);
//await context.Response.Body.FlushAsync();
}
else break;
}
queue.CompleteAdding();
_schedulerFactory.OnChanged -= Subscribe;
}
break;
// 登录验证
case "/login":
var username = context.Request.Form["username"];
var password = context.Request.Form["password"];
try
{
// 调用自定义验证逻辑
if (Options.LoginConfig?.OnLoging is not null && await Options.LoginConfig.OnLoging(username, password, context).ConfigureAwait(false))
{
context.Response.StatusCode = StatusCodes.Status200OK;
await context.Response.WriteAsync("OK").ConfigureAwait(false);
}
else
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("username or password error").ConfigureAwait(false);
}
}
catch (Exception ex)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsync(ex.Message).ConfigureAwait(false);
}
break;
// 未处理接口
default:
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("Not Found").ConfigureAwait(false);
return;
}
}
/// <summary>
/// 将对象输出为 JSON 字符串
/// </summary>
/// <param name="obj">对象</param>
/// <returns><see cref="string"/></returns>
private static string SerializeToJson(object obj)
{
// 初始化默认序列化选项
var jsonSerializerOptions = Penetrates.GetDefaultJsonSerializerOptions();
jsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
jsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
jsonSerializerOptions.WriteIndented = false;
return JsonSerializer.Serialize(obj, jsonSerializerOptions);
}
}