280 lines
10 KiB
C#
280 lines
10 KiB
C#
using System.Text;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.AspNetCore.Http.Features;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.FileProviders;
|
||
using Microsoft.AspNetCore.StaticFiles;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using Microsoft.OpenApi.Models;
|
||
using MilliSim.Data;
|
||
using MilliSim.Services.Infrastructure;
|
||
using MilliSim.Services;
|
||
using Serilog;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
// 配置 Kestrel 服务器,允许大文件上传(最大 2GB)
|
||
builder.WebHost.ConfigureKestrel(options =>
|
||
{
|
||
options.Limits.MaxRequestBodySize = 2147483648; // 2GB
|
||
});
|
||
|
||
// 配置表单选项,允许大文件上传
|
||
builder.Services.Configure<FormOptions>(options =>
|
||
{
|
||
options.MultipartBodyLengthLimit = 2147483648; // 2GB
|
||
});
|
||
|
||
// Serilog
|
||
Log.Logger = new LoggerConfiguration()
|
||
.ReadFrom.Configuration(builder.Configuration)
|
||
.WriteTo.Console()
|
||
.WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
|
||
.CreateLogger();
|
||
builder.Host.UseSerilog();
|
||
|
||
// 数据库
|
||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||
var serverVersion = ServerVersion.AutoDetect(new MySqlConnector.MySqlConnection(connectionString));
|
||
builder.Services.AddDbContext<MilliSimDbContext>(options =>
|
||
options.UseMySql(connectionString, serverVersion, mysql =>
|
||
{
|
||
mysql.MigrationsAssembly("MilliSim.Api");
|
||
}));
|
||
|
||
// 基础服务
|
||
builder.Services.AddHttpContextAccessor();
|
||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||
builder.Services.AddScoped<IJwtService, JwtService>();
|
||
builder.Services.AddSingleton<ICaptchaService, CaptchaService>();
|
||
builder.Services.AddScoped<IFileStorageService, FileStorageService>();
|
||
builder.Services.AddScoped<IOperationLogService, OperationLogService>();
|
||
builder.Services.AddScoped<IUploadTrackingService, UploadTrackingService>();
|
||
|
||
// 业务服务
|
||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||
builder.Services.AddScoped<IUserService, UserService>();
|
||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||
builder.Services.AddScoped<IAuthorizationService, AuthorizationService>();
|
||
builder.Services.AddScoped<IContentService, ContentService>();
|
||
builder.Services.AddScoped<ISystemService, SystemService>();
|
||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||
|
||
// 后台定时任务:授权到期提醒
|
||
builder.Services.AddHostedService<AuthorizationNotificationJob>();
|
||
|
||
// 后台定时任务:垃圾资源清理(每 6 小时清理 24h 未消费的上传文件)
|
||
builder.Services.AddHostedService<UploadCleanupJob>();
|
||
|
||
// JWT 认证
|
||
var secretKey = builder.Configuration["Jwt:SecretKey"]!;
|
||
var issuer = builder.Configuration["Jwt:Issuer"];
|
||
var audience = builder.Configuration["Jwt:Audience"];
|
||
|
||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||
.AddJwtBearer(options =>
|
||
{
|
||
options.TokenValidationParameters = new TokenValidationParameters
|
||
{
|
||
ValidateIssuer = true,
|
||
ValidateAudience = true,
|
||
ValidateLifetime = true,
|
||
ValidateIssuerSigningKey = true,
|
||
ValidIssuer = issuer,
|
||
ValidAudience = audience,
|
||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
||
ClockSkew = TimeSpan.Zero
|
||
};
|
||
// 允许通过 query string 的 access_token 传递 JWT(用于下载端点,浏览器无法在 window.open/锚点点击中设置 Authorization 头)
|
||
options.Events = new JwtBearerEvents
|
||
{
|
||
OnMessageReceived = context =>
|
||
{
|
||
var accessToken = context.Request.Query["access_token"];
|
||
if (!string.IsNullOrEmpty(accessToken))
|
||
{
|
||
context.Token = accessToken;
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
};
|
||
});
|
||
|
||
builder.Services.AddAuthorization(options =>
|
||
{
|
||
options.AddPolicy("Admin", policy => policy.RequireRole("Admin", "4"));
|
||
options.AddPolicy("Sales", policy => policy.RequireRole("Sales", "2", "Admin", "4"));
|
||
options.AddPolicy("ProductManager", policy => policy.RequireRole("ProductManager", "3", "Admin", "4"));
|
||
options.AddPolicy("Staff", policy => policy.RequireRole("Sales", "2", "ProductManager", "3", "Admin", "4"));
|
||
});
|
||
|
||
// CORS
|
||
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||
builder.Services.AddCors(options =>
|
||
{
|
||
options.AddPolicy("MilliSimCors", policy =>
|
||
{
|
||
policy.WithOrigins(allowedOrigins)
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader()
|
||
.AllowCredentials();
|
||
});
|
||
});
|
||
|
||
// Swagger
|
||
builder.Services.AddEndpointsApiExplorer();
|
||
builder.Services.AddSwaggerGen(c =>
|
||
{
|
||
c.SwaggerDoc("v1", new OpenApiInfo { Title = "MilliSim API", Version = "v1" });
|
||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||
{
|
||
Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
|
||
Name = "Authorization",
|
||
In = ParameterLocation.Header,
|
||
Type = SecuritySchemeType.ApiKey,
|
||
Scheme = "Bearer"
|
||
});
|
||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||
{
|
||
{
|
||
new OpenApiSecurityScheme
|
||
{
|
||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
|
||
},
|
||
Array.Empty<string>()
|
||
}
|
||
});
|
||
});
|
||
|
||
// 控制器
|
||
builder.Services.AddControllers()
|
||
.AddJsonOptions(options =>
|
||
{
|
||
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||
options.JsonSerializerOptions.WriteIndented = false;
|
||
// 添加北京时间转换器,将DateTime序列化为北京时间格式 "yyyy-MM-dd HH:mm:ss"
|
||
options.JsonSerializerOptions.Converters.Add(new BeijingTimeConverter());
|
||
options.JsonSerializerOptions.Converters.Add(new NullableBeijingTimeConverter());
|
||
});
|
||
|
||
// 静态文件(通过 app.UseStaticFiles() 启用)
|
||
|
||
var app = builder.Build();
|
||
|
||
// 自动迁移与种子
|
||
using (var scope = app.Services.CreateScope())
|
||
{
|
||
var db = scope.ServiceProvider.GetRequiredService<MilliSimDbContext>();
|
||
try
|
||
{
|
||
db.Database.EnsureCreated();
|
||
await DbSeeder.SeedAsync(db);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, "数据库初始化失败");
|
||
}
|
||
}
|
||
|
||
// 中间件
|
||
if (app.Environment.IsDevelopment())
|
||
{
|
||
app.UseSwagger();
|
||
app.UseSwaggerUI();
|
||
}
|
||
|
||
// 注意:不使用 app.UseStaticFiles()(默认暴露 wwwroot/ 下所有文件,含 backups/ 数据库备份)
|
||
// 仅通过下方受控的 /uploads/ 中间件暴露上传资源,其他 wwwroot 路径一律不可直接访问
|
||
|
||
// 读取本地存储配置(LocalBasePath/LocalBaseUrl):供签名验证中间件与静态文件中间件共用
|
||
// 在应用启动时读取一次,运行时若后台修改 LocalBaseUrl 需重启服务才生效(安全权衡:避免每请求查库)
|
||
string localBaseUrl = "/uploads";
|
||
string localBasePath = "wwwroot/uploads";
|
||
try
|
||
{
|
||
using var storageScope = app.Services.CreateScope();
|
||
var storageDb = storageScope.ServiceProvider.GetRequiredService<MilliSimDbContext>();
|
||
var localBasePathSetting = await storageDb.SystemSettings.FirstOrDefaultAsync(s => s.Key == "LocalBasePath");
|
||
var localBaseUrlSetting = await storageDb.SystemSettings.FirstOrDefaultAsync(s => s.Key == "LocalBaseUrl");
|
||
localBasePath = localBasePathSetting?.Value ?? "wwwroot/uploads";
|
||
localBaseUrl = localBaseUrlSetting?.Value ?? "/uploads";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, "读取本地存储配置失败,使用默认值 /uploads");
|
||
}
|
||
|
||
// 安全中间件1:拦截 /backups/ 路径的直接访问,数据库备份只能通过认证 API 下载
|
||
// (/backups/ 物理目录位于 wwwroot/backups/,不能被静态文件中间件暴露)
|
||
app.Use(async (context, next) =>
|
||
{
|
||
if (context.Request.Path.StartsWithSegments("/backups", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
context.Response.StatusCode = 404;
|
||
return;
|
||
}
|
||
await next();
|
||
});
|
||
|
||
// 安全中间件2:验证 Local 模式 /uploads/ 资源的签名URL(与 COS 临时签名URL对齐)
|
||
// Local 模式 GetSignedUrl 返回 /uploads/{path}?exp={ts}&sig={hmac},此中间件校验签名与过期时间
|
||
// 无效/过期/缺失签名一律返回 403,防止 Local 模式资源被永久链接盗用
|
||
// 注意:必须在 UseStaticFiles 之前注册,否则静态文件中间件会先放行
|
||
var localSigningKey = app.Configuration["Jwt:SecretKey"]!;
|
||
var localBaseUrlPrefix = localBaseUrl.TrimEnd('/') + "/";
|
||
app.Use(async (context, next) =>
|
||
{
|
||
var path = context.Request.Path.Value ?? string.Empty;
|
||
if (path.StartsWith(localBaseUrlPrefix, StringComparison.OrdinalIgnoreCase)
|
||
|| path.Equals(localBaseUrl, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var exp = context.Request.Query["exp"].FirstOrDefault();
|
||
var sig = context.Request.Query["sig"].FirstOrDefault();
|
||
if (!FileStorageService.ValidateLocalSignedUrl(context.Request.Path, exp, sig, localSigningKey))
|
||
{
|
||
context.Response.StatusCode = 403;
|
||
return;
|
||
}
|
||
}
|
||
await next();
|
||
});
|
||
|
||
// 配置本地存储静态文件中间件:根据数据库 LocalBasePath/LocalBaseUrl 配置
|
||
// 支持相对路径(相对于 ContentRootPath)或绝对路径,使本地模式上传的文件可通过 HTTP 访问
|
||
try
|
||
{
|
||
var absolutePath = Path.IsPathRooted(localBasePath)
|
||
? localBasePath
|
||
: Path.Combine(app.Environment.ContentRootPath, localBasePath);
|
||
|
||
if (!Directory.Exists(absolutePath))
|
||
Directory.CreateDirectory(absolutePath);
|
||
|
||
// Unity WebGL 资源使用 .unityweb 扩展名(gzip 压缩的二进制流),默认 ContentTypeProvider 不识别会 404
|
||
var contentTypeProvider = new FileExtensionContentTypeProvider();
|
||
contentTypeProvider.Mappings[".unityweb"] = "application/octet-stream";
|
||
|
||
app.UseStaticFiles(new StaticFileOptions
|
||
{
|
||
FileProvider = new PhysicalFileProvider(absolutePath),
|
||
RequestPath = localBaseUrl,
|
||
ContentTypeProvider = contentTypeProvider
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, "配置本地存储静态文件中间件失败");
|
||
}
|
||
|
||
app.UseCors("MilliSimCors");
|
||
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
|
||
app.MapControllers();
|
||
|
||
app.MapGet("/", () => Results.Redirect("/swagger"));
|
||
|
||
app.Run();
|