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 MoShiro.Backend.Application; namespace MoShiro.Backend.Api; /// /// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 里加一行。 /// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次()。 /// public static class ApiModule { public static IServiceCollection AddMoShiroApi(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.Configure(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; } /// /// 跨域(可选):只对 /api/public 和 /api/admin 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域), /// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。 /// public static IApplicationBuilder UseMoShiroCors(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); }); } /// 认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。 public static IApplicationBuilder UseMoShiroApi(this IApplicationBuilder app) { app.UseExceptionHandler(); app.UseStatusCodePages(); app.Use(PrincipalResolver.ResolveAsync); return app; } public static IEndpointRouteBuilder MapMoShiroApi(this IEndpointRouteBuilder app) { RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync); QueryEndpoints.Map(publicGroup); LiveEndpoints.Map(publicGroup); HostEndpoints.Map(publicGroup); NodeEndpoints.Map(publicGroup); CatalogEndpoints.Map(publicGroup); RouteGroupBuilder admin = app.MapGroup("/api/admin"); AuthEndpoints.MapLogin(admin); RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync); AuthEndpoints.MapSession(guarded); AgentEndpoints.Map(guarded); TaskEndpoints.Map(guarded); DataEndpoints.Map(guarded); AuditEndpoints.Map(guarded); TrustEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); ReleaseKeyEndpoints.Map(guarded); LinkEndpoints.Map(guarded); OpsEndpoints.Map(app); BuildEndpoints.MapOffer(app); app.MapOpenApi("/openapi/v1.json"); app.MapStaticSite(); 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", }; }