Files
Thing/ThingHK/HttpEndpoints.cs
T

172 lines
7.4 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.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ThingHK;
/// <summary>
/// HTTP 服务层:minimal API 风格扩展方法。
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
/// GET /snapshot → 一次性拉取最新缓存快照
/// GET /stream → SSE 订阅,Kernel 主动推送 SensorSnapshot
/// POST /config → 运行时调整采样间隔
/// GET / → 健康检查(200 OK)
/// </summary>
internal static class HttpEndpoints
{
public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel)
{
// 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错
app.Use(async (ctx, next) =>
{
try { await next(); }
catch (Exception ex)
{
Console.Error.WriteLine($"[HTTP] {ctx.Request.Method} {ctx.Request.Path} 异常: {ex}");
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync($"{{\"error\":\"internal\",\"message\":\"{ex.Message.Replace("\"", "\\\"")}\"}}");
}
});
// 根路由:进程存活检查(curl 友好)
// AOT 下必须用 Results.Json + TypeInfoResults.Ok(object) 会走默认 options 抛 JsonTypeInfo 异常
app.MapGet("/", () => Results.Json(new HealthResponse(), ThingHKJsonContext.Default.HealthResponse));
// 冷启动就绪探测
app.MapGet("/status", () =>
{
var hw = kernel.Hardware;
var snap = kernel.Scheduler.Cache.GetLatest();
var status = new KernelStatus
{
Ready = hw?.Ready ?? false,
IsAdmin = hw?.IsAdmin ?? false,
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
GroupCount = snap?.Groups.Count ?? 0,
SensorCount = kernel.Scheduler.Cache.SensorCount,
Providers = snap?.Groups.Select(g => g.Id).ToList() ?? new List<string>(),
};
return Results.Json(status, ThingHKJsonContext.Default.KernelStatus);
});
// 一次性快照
app.MapGet("/snapshot", (HttpContext ctx) =>
{
var snap = kernel.Scheduler.Cache.GetLatest();
if (snap == null)
{
ctx.Response.StatusCode = 404;
return Results.Json(new ErrorResponse("not_ready", "Kernel 尚未完成首轮扫描"), ThingHKJsonContext.Default.ErrorResponse);
}
return Results.Json(snap, ThingHKJsonContext.Default.SensorSnapshot);
});
// SSE 推送流
app.MapGet("/stream", async (HttpContext ctx, CancellationToken ct) =>
{
// 强制 text/event-stream,禁用响应缓冲(SSE 必须 flush)
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
// 先推一次当前缓存快照,避免客户端等待一个 tick 才有数据
var initial = kernel.Scheduler.Cache.GetLatest();
if (initial != null)
{
await WriteSseEventAsync(ctx, initial, ct).ConfigureAwait(false);
}
// 订阅广播
await foreach (var snap in kernel.Scheduler.SubscribeAsync(ct).ConfigureAwait(false))
{
await WriteSseEventAsync(ctx, snap, ct).ConfigureAwait(false);
}
});
// 运行时配置调整
// 注意:async lambda 必须返回 Task<IResult>,单纯返回 IResult 会被框架当成 Task<IResult> 的未等待任务,导致响应体为空
app.MapPost("/config", async Task<IResult> (HttpContext ctx) =>
{
// 先读 body 为字符串,再反序列化。
// 在 AOT 下比 DeserializeAsync 对 nullable 属性更稳定。
using var reader = new StreamReader(ctx.Request.Body);
var body = await reader.ReadToEndAsync(ctx.RequestAborted);
ConfigRequest? req;
try
{
req = string.IsNullOrWhiteSpace(body)
? new ConfigRequest()
: JsonSerializer.Deserialize(body, ThingHKJsonContext.Default.ConfigRequest);
}
catch (JsonException ex)
{
ctx.Response.StatusCode = 400;
return Results.Json(new ErrorResponse("invalid_json", ex.Message), ThingHKJsonContext.Default.ErrorResponse);
}
if (req == null)
{
ctx.Response.StatusCode = 400;
return Results.Json(new ErrorResponse("invalid_body", "请求体为空"), ThingHKJsonContext.Default.ErrorResponse);
}
kernel.Scheduler.UpdateIntervals(req.FastIntervalMs, req.SlowIntervalMs);
return Results.Json(new ConfigResponse
{
Success = true,
FastIntervalMs = kernel.Scheduler.FastIntervalMs,
SlowIntervalMs = kernel.Scheduler.SlowIntervalMs,
StreamIntervalMs = kernel.Scheduler.FastIntervalMs,
}, ThingHKJsonContext.Default.ConfigResponse);
});
return app;
}
/// <summary>
/// 写一条 SSE 事件:event: snapshot\ndata: {json}\n\n
/// </summary>
private static async Task WriteSseEventAsync(HttpContext ctx, SensorSnapshot snap, CancellationToken ct)
{
await ctx.Response.WriteAsync("event: snapshot\n", ct).ConfigureAwait(false);
await ctx.Response.WriteAsync("data: ", ct).ConfigureAwait(false);
// 直接用流式序列化,避免大字符串分配
await JsonSerializer.SerializeAsync(
ctx.Response.Body, snap, ThingHKJsonContext.Default.SensorSnapshot, ct).ConfigureAwait(false);
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
await ctx.Response.Body.FlushAsync(ct).ConfigureAwait(false);
}
}
/// <summary>
/// Kernel 宿主:持有 HardwareManager + SamplingScheduler,管理整体生命周期。
/// Program.cs 构造此对象并注入到 DI 容器供 endpoint 使用。
/// </summary>
internal sealed class KernelHost : IDisposable
{
public HardwareManager? Hardware { get; private set; }
public SamplingScheduler Scheduler { get; private set; } = null!;
public Stopwatch Uptime { get; } = Stopwatch.StartNew();
public async Task StartAsync(bool basic, int fastMs, int slowMs)
{
// 1. 初始化硬件层(含首轮 Update,约 5 秒)
Hardware = new HardwareManager(basic);
Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}");
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
Scheduler = new SamplingScheduler(Hardware, fastMs, slowMs);
await Scheduler.StartAsync();
Console.Error.WriteLine($"[ThingHK] 调度器已启动: fast={Scheduler.FastIntervalMs}ms slow={Scheduler.SlowIntervalMs}ms");
}
public void Dispose()
{
Console.Error.WriteLine("[ThingHK] Kernel 正在关闭...");
Scheduler?.Dispose();
Hardware?.Dispose();
}
}