using System.Security.Claims; using System.Text.Json.Serialization; using System.Threading.RateLimiting; using EARSystem.Core; using EARSystem.Infrastructure; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; var seedOnly = args.Contains("--seed-demo", StringComparer.Ordinal); var builder = WebApplication.CreateBuilder(args.Where(x => x != "--seed-demo").ToArray()); if (seedOnly && !builder.Environment.IsDevelopment()) throw new InvalidOperationException("--seed-demo 只能用于 Development 环境。"); builder.Services.AddDbContext(o => o.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddIdentity(o => { o.Password.RequiredLength = 10; o.User.RequireUniqueEmail = true; o.Lockout.MaxFailedAccessAttempts = 5; o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); }).AddEntityFrameworkStores().AddDefaultTokenProviders(); builder.Services.ConfigureApplicationCookie(o => { o.LoginPath = "/Account/Login"; o.AccessDeniedPath = "/Account/Denied"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax; o.Cookie.SecurePolicy = builder.Environment.IsProduction() ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; o.ExpireTimeSpan = TimeSpan.FromDays(7); o.SlidingExpiration = true; o.Events.OnRedirectToLogin = context => { if (context.Request.Path.StartsWithSegments("/api")) context.Response.StatusCode = 401; else context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; o.Events.OnRedirectToAccessDenied = context => { if (context.Request.Path.StartsWithSegments("/api")) context.Response.StatusCode = 403; else context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; }); builder.Services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN"); builder.Services.AddControllersWithViews(o => o.Filters.Add(new AutoValidateAntiforgeryTokenAttribute())) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "keys"))) .SetApplicationName("EARSystem"); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("AMap")); builder.Services.AddHttpClient(http => http.Timeout = TimeSpan.FromSeconds(8)).RemoveAllLoggers(); builder.Services.AddHttpClient("MapProxy", http => http.Timeout = TimeSpan.FromSeconds(8)).RemoveAllLoggers(); builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = 429; o.AddPolicy("maps", context => RateLimitPartition.GetFixedWindowLimiter( context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? context.Connection.RemoteIpAddress?.ToString() ?? "anonymous", _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); }); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning); var app = builder.Build(); app.UseExceptionHandler(errorApp => errorApp.Run(async context => { var error = context.Features.Get()?.Error; var (status, message) = error switch { DbUpdateConcurrencyException => (409, "记录已在其他设备修改。请保留当前输入,重新加载最新记录后再保存。"), MapServiceException ex => (503, ex.Message), OperationCanceledException => (503, "请求超时,请重试;地图服务不可用时可以切换演示模式。"), KeyNotFoundException => (404, "记录不存在。"), ArgumentException ex => (400, ex.Message), DbUpdateException => (409, "记录冲突,请刷新后重试。"), _ => (500, "服务暂时发生错误,请稍后重试。") }; context.Response.StatusCode = status; context.Response.ContentType = "application/problem+json"; app.Logger.LogWarning("Request failed: {ErrorType}, status {Status}", error?.GetType().Name, status); await context.Response.WriteAsJsonAsync(new ProblemDetails { Status = status, Title = message }); })); if (app.Environment.IsProduction()) { app.UseHsts(); app.UseHttpsRedirection(); } app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}"); await using (var scope = app.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (app.Environment.IsEnvironment("Testing")) await db.Database.EnsureCreatedAsync(); else if (app.Environment.IsDevelopment()) await db.Database.MigrateAsync(); var demo = !app.Environment.IsProduction() && (seedOnly || builder.Configuration.GetValue("Demo:Enabled", false)); await scope.ServiceProvider.GetRequiredService().InitializeAsync(demo, app.Environment.IsDevelopment() ? Path.Combine(app.Environment.ContentRootPath, "App_Data", "demo-credentials.txt") : null, builder.Configuration["BootstrapAdmin:Email"], builder.Configuration["BootstrapAdmin:Password"], builder.Configuration.GetValue("Demo:Longitude", 120.155), builder.Configuration.GetValue("Demo:Latitude", 30.274)); } if (seedOnly) { Console.WriteLine("演示数据初始化完成。已存在的数据保持不变,账号凭据保存在本地 App_Data/demo-credentials.txt。"); return; } app.Run(); public partial class Program { }