using System.Text; using System.Threading.RateLimiting; using MiaoJiZhang.Api.Services; using Microsoft.AspNetCore.RateLimiting; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddOpenApi(); var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 10)); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = authPermitLimit, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, })); options.AddPolicy("admin-auth", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, })); options.AddPolicy("ai", context => RateLimitPartition.GetConcurrencyLimiter( context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new ConcurrencyLimiterOptions { PermitLimit = 2, QueueLimit = 0, })); options.AddPolicy("upload", context => RateLimitPartition.GetFixedWindowLimiter( context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, })); }); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient("LlmClient"); builder.Services.AddHttpClient("PushProviders", client => { client.Timeout = TimeSpan.FromSeconds(20); }); foreach (var provider in new[] { "huawei", "honor", "xiaomi", "oppo", "vivo", "meizu", }) { builder.Services.AddSingleton(services => new OfficialPushProvider( provider, services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService>())); } builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); var conn = builder.Configuration.GetConnectionString("Default"); if (string.IsNullOrWhiteSpace(conn)) throw new InvalidOperationException("必须通过 ConnectionStrings__Default 配置数据库连接"); var jwtSecret = builder.Configuration["Jwt:Secret"]; if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32) throw new InvalidOperationException("必须通过 Jwt__Secret 配置至少 32 位的 JWT 密钥"); if (builder.Configuration.GetValue("Push:Enabled")) { var pushKey = builder.Configuration["Push:TokenEncryptionKey"]; byte[]? key = null; try { key = string.IsNullOrWhiteSpace(pushKey) ? null : Convert.FromBase64String(pushKey); } catch (FormatException) { } if (key?.Length != 32) throw new InvalidOperationException( "启用推送时必须通过 Push__TokenEncryptionKey 配置 base64 编码的 32 字节密钥"); } builder.Services.AddDbContext(o => o.UseMySql(conn, ServerVersion.AutoDetect(conn))); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSecret)), }; o.Events = new JwtBearerEvents { OnTokenValidated = async context => { var idValue = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.Principal?.FindFirst("sub")?.Value; if (!long.TryParse(idValue, out var userId)) { context.Fail("Invalid user identity"); return; } var versionValue = context.Principal?.FindFirst("auth_version")?.Value; var tokenVersion = int.TryParse(versionValue, out var parsedVersion) ? parsedVersion : 0; var db = context.HttpContext.RequestServices.GetRequiredService(); var userState = await db.Users .AsNoTracking() .Where(user => user.Id == userId) .Select(user => new { user.AuthVersion, user.IsBanned, user.AccountClosureScheduledAt, }) .FirstOrDefaultAsync(context.HttpContext.RequestAborted); if (userState is null || userState.IsBanned || userState.AccountClosureScheduledAt != null || userState.AuthVersion != tokenVersion) { context.Fail("Account session has been revoked"); } }, }; }); builder.Services.AddAuthorization(); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); await AppConfigDefaults.EnsureAsync(db); await scope.ServiceProvider.GetRequiredService().EnsureAsync(); if (app.Environment.IsDevelopment()) await DbSeeder.SeedAsync(db); } if (app.Environment.IsDevelopment()) app.MapOpenApi(); var buildTime = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + " UTC"; var apiVersion = builder.Configuration["Build:Version"] ?? "dev"; app.UseAuthentication(); app.UseRateLimiter(); app.UseAuthorization(); app.UseMiddleware(); app.MapControllers(); app.MapGet("/api/ping", () => Results.Ok(new { status = "ok", version = apiVersion, built = buildTime })); app.MapGet("/api/version", () => Results.Ok(new { app = "记之 API", version = apiVersion, built = buildTime })); app.UseDefaultFiles(); app.UseStaticFiles(); app.Run(); public partial class Program;