194 lines
7.9 KiB
C#
194 lines
7.9 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();
|
|
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<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;
|
|
|