152 lines
6.2 KiB
C#
152 lines
6.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();
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddPolicy("auth", context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 10,
|
|
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<AiPermissionFilter>();
|
|
builder.Services.AddHttpClient("LlmClient");
|
|
builder.Services.AddSingleton<OpenAiVisionClient>();
|
|
builder.Services.AddSingleton<ILlmClient>(sp => sp.GetRequiredService<OpenAiVisionClient>());
|
|
builder.Services.AddHostedService<RecycleBinCleanupService>();
|
|
builder.Services.AddScoped<AccountDataEraser>();
|
|
builder.Services.AddHostedService<AccountClosureCleanupService>();
|
|
|
|
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 密钥");
|
|
var adminKey = builder.Configuration["Admin:Key"];
|
|
if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
|
|
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
|
|
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();
|
|
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.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;
|
|
|