280 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();