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(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(options => options.UseMySql(connectionString, serverVersion, mysql => { mysql.MigrationsAssembly("MilliSim.Api"); })); // 基础服务 builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); 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.AddScoped(); builder.Services.AddScoped(); // 后台定时任务:授权到期提醒 builder.Services.AddHostedService(); // 后台定时任务:垃圾资源清理(每 6 小时清理 24h 未消费的上传文件) builder.Services.AddHostedService(); // 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() ?? Array.Empty(); 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() } }); }); // 控制器 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(); 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(); 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();