Files

197 lines
8.2 KiB
C#

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<JwtService>();
builder.Services.AddScoped<AgentService>();
builder.Services.AddScoped<BudgetRecommendationService>();
builder.Services.AddScoped<ReplyService>();
builder.Services.AddScoped<LedgerResolver>();
builder.Services.AddScoped<AiPermissionService>();
builder.Services.AddScoped<AiChatQuotaService>();
builder.Services.AddScoped<BudgetPushService>();
builder.Services.AddScoped<AdminSessionService>();
builder.Services.AddScoped<AdminBootstrapService>();
builder.Services.AddSingleton<LlmSecretProtector>();
builder.Services.AddSingleton<PushTokenProtector>();
builder.Services.AddScoped<AiPermissionFilter>();
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<IPushProvider>(services => new OfficialPushProvider(
provider,
services.GetRequiredService<IConfiguration>(),
services.GetRequiredService<IHttpClientFactory>(),
services.GetRequiredService<ILogger<OfficialPushProvider>>()));
}
builder.Services.AddSingleton<PushProviderRegistry>();
builder.Services.AddSingleton<OpenAiVisionClient>();
builder.Services.AddSingleton<ILlmClient>(sp => sp.GetRequiredService<OpenAiVisionClient>());
builder.Services.AddHostedService<RecycleBinCleanupService>();
builder.Services.AddScoped<AccountDataEraser>();
builder.Services.AddHostedService<AccountClosureCleanupService>();
builder.Services.AddHostedService<PushDispatchService>();
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<bool>("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<AppDbContext>(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<AppDbContext>();
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<AppDbContext>();
await db.Database.MigrateAsync();
await AppConfigDefaults.EnsureAsync(db);
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
// These records are runtime defaults, not development fixtures. Production
// databases also need them for onboarding, category fallback and stickers.
// DbSeeder only inserts into an empty catalog, so existing admin-managed
// records are preserved.
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<AdminAuditMiddleware>();
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;