chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,130 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Monitor.Backend.Application;
namespace Monitor.Backend.Api;
/// <summary>
/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 <see cref="MapMonitorApi"/> 里加一行。
/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次(<see cref="ErrorCodeHttpMap"/>)。
/// </summary>
public static class ApiModule
{
public static IServiceCollection AddMonitorApi(this IServiceCollection services)
{
services.AddSingleton<SseLimiter>();
services.Configure<JsonOptions>(options =>
{
JsonSerializerOptions json = options.SerializerOptions;
json.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
json.DictionaryKeyPolicy = null;
json.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
json.Converters.Add(new Rfc3339Converter());
json.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
});
// 框架自己产生的错误(请求体格式不对、路由不存在、未处理的异常)也统一成 problem+json,并带上 code 和 trace_id
services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
{
int status = context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode;
context.ProblemDetails.Extensions["code"] = DefaultCode(status);
context.ProblemDetails.Extensions["trace_id"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
if (status >= 500)
{
context.ProblemDetails.Detail = null;
}
});
services.AddOpenApi("v1");
return services;
}
/// <summary>
/// 跨域(可选):只对 <c>/api/public</c> 和 <c>/api/admin</c> 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域),
/// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。
/// </summary>
public static IApplicationBuilder UseMonitorCors(this IApplicationBuilder app, string? origins)
{
string[] allowed = (origins ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (allowed.Length == 0)
{
return app;
}
bool any = allowed.Contains("*");
return app.Use(async (http, next) =>
{
string? origin = http.Request.Headers.Origin;
if (origin is null || !http.Request.Path.StartsWithSegments("/api") || !(any || allowed.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase)))
{
await next(http);
return;
}
http.Response.Headers.AccessControlAllowOrigin = any ? "*" : origin;
http.Response.Headers.Vary = "Origin";
http.Response.Headers.AccessControlExposeHeaders = "Retry-After";
if (HttpMethods.IsOptions(http.Request.Method) && http.Request.Headers.ContainsKey("Access-Control-Request-Method"))
{
http.Response.Headers.AccessControlAllowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
http.Response.Headers.AccessControlAllowHeaders = "Authorization, Content-Type, X-CSRF-Token";
http.Response.Headers.AccessControlMaxAge = "600";
http.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
await next(http);
});
}
/// <summary>认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。</summary>
public static IApplicationBuilder UseMonitorApi(this IApplicationBuilder app)
{
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Use(PrincipalResolver.ResolveAsync);
return app;
}
public static IEndpointRouteBuilder MapMonitorApi(this IEndpointRouteBuilder app)
{
RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync);
QueryEndpoints.Map(publicGroup);
LiveEndpoints.Map(publicGroup);
HostEndpoints.Map(publicGroup);
CatalogEndpoints.Map(publicGroup);
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
TaskEndpoints.Map(guarded);
DataEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
OpsEndpoints.Map(app);
app.MapOpenApi("/openapi/v1.json");
return app;
}
private static string DefaultCode(int status) => status switch
{
400 => "validation_failed",
401 => "unauthorized",
403 => "forbidden",
404 => "not_found",
405 => "method_not_allowed",
413 => "payload_too_large",
415 => "unsupported_media_type",
429 => "too_many_requests",
503 => "storage_unavailable",
_ => "internal_error",
};
}