从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
10 KiB
C#
224 lines
10 KiB
C#
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.OpenApi.Models;
|
||
using MiGu.Server.Auth;
|
||
using MiGu.Server.Configs;
|
||
using MiGu.Server.Launcher;
|
||
using Yarp.ReverseProxy.Transforms;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||
// 作为 WebRoot —— 解决「每次 vite build → wwwroot/index.html 都被 hash 漂移 → 提一个噪音 commit」
|
||
// 的死循环。
|
||
//
|
||
// 探测策略:从 ContentRootPath 出发向上逐级找 frontends/apps/simple-platform-vue/dist/index.html,
|
||
// 找到则将 WebRootPath 改指到 dist;找不到则保持默认 wwwroot/(用于「克隆后没跑 build」或
|
||
// 生产部署机不带 node 的场景,wwwroot/ 由 build-platform-frontend.bat 的 robocopy /MIR 兜底)。
|
||
//
|
||
// 影响:
|
||
// - dev 模式:跑过一次 `pnpm build` 后无需 robocopy,重启 MiGu.Server 即生效;
|
||
// 也不再需要 commit wwwroot/index.html。
|
||
// - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/,dist 此时不存在
|
||
// 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。
|
||
{
|
||
static string? FindDistRoot(string startDir)
|
||
{
|
||
var dir = new DirectoryInfo(startDir);
|
||
while (dir != null)
|
||
{
|
||
var candidate = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html");
|
||
if (File.Exists(candidate)) return Path.GetDirectoryName(candidate);
|
||
dir = dir.Parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
var dist = FindDistRoot(builder.Environment.ContentRootPath);
|
||
if (dist != null)
|
||
{
|
||
builder.Environment.WebRootPath = dist;
|
||
Console.WriteLine($"[MiGu.Server] WebRoot -> dist: {dist}");
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"[MiGu.Server] WebRoot -> wwwroot (dist not found): {builder.Environment.WebRootPath}");
|
||
}
|
||
}
|
||
|
||
// 控制器 + JSON 默认大小写、忽略 null
|
||
builder.Services.AddControllers()
|
||
.AddJsonOptions(opt =>
|
||
{
|
||
opt.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||
opt.JsonSerializerOptions.WriteIndented = false;
|
||
});
|
||
|
||
builder.Services.AddEndpointsApiExplorer();
|
||
builder.Services.AddSwaggerGen(c =>
|
||
{
|
||
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
|
||
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||
{
|
||
Name = "Authorization",
|
||
Description = "JWT bearer。值: \"Bearer {token}\"",
|
||
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>()
|
||
}
|
||
});
|
||
});
|
||
|
||
// CORS(Vite dev 5173 与 MiGu.Server 8080 跨域)
|
||
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
|
||
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
||
|
||
// ─── AR-3 / AR-5:JWT + Cookie 双轨鉴权 ─────────────────────────────────────────
|
||
//
|
||
// 设计:
|
||
// - access token 同时通过 Authorization: Bearer header 与 httpOnly Cookie 两路传输;
|
||
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
||
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
||
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
|
||
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
|
||
// 共享密钥(YARP transform 自动追加)。
|
||
builder.Services.AddSingleton<UserStore>();
|
||
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
||
{
|
||
var config = sp.GetRequiredService<IConfiguration>();
|
||
var logger = sp.GetRequiredService<ILogger<JwtIssuer>>();
|
||
var secret = config["Jwt:Secret"] ?? JwtIssuer.PlaceholderSecret;
|
||
var issuer = config["Jwt:Issuer"] ?? "MiGu.Server";
|
||
var audience = config["Jwt:Audience"] ?? "platform.client";
|
||
var lifetimeMinutes = int.TryParse(config["Jwt:LifetimeMinutes"], out var m) && m > 0 ? m : 24 * 60;
|
||
return new JwtIssuer(secret, issuer, audience, TimeSpan.FromMinutes(lifetimeMinutes), logger);
|
||
});
|
||
builder.Services.AddSingleton<InternalTokenStore>();
|
||
|
||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||
.AddJwtBearer(opt =>
|
||
{
|
||
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
|
||
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||
{
|
||
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
|
||
ValidateIssuer = false,
|
||
ValidateAudience = false,
|
||
ValidateIssuerSigningKey = false,
|
||
ValidateLifetime = false,
|
||
};
|
||
opt.Events = new JwtBearerEvents
|
||
{
|
||
OnMessageReceived = ctx =>
|
||
{
|
||
// 优先从 Authorization: Bearer 取;没有再从 Cookie 取。
|
||
if (string.IsNullOrEmpty(ctx.Token))
|
||
{
|
||
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
||
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
||
}
|
||
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
|
||
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
|
||
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
|
||
return Task.CompletedTask;
|
||
}
|
||
};
|
||
});
|
||
|
||
builder.Services.AddAuthorization(opts =>
|
||
{
|
||
// Platform scope:完整管理端权限,对应 admin 账号。
|
||
opts.AddPolicy("PlatformScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "Platform"));
|
||
// RCSMonitor scope:运营白名单。
|
||
opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor"));
|
||
// 任一登录用户。
|
||
opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser());
|
||
});
|
||
|
||
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。
|
||
builder.Services.AddReverseProxy()
|
||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||
.AddTransforms(tctx =>
|
||
{
|
||
// 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||
if (tctx.Route.RouteId != "sl-route") return;
|
||
tctx.AddRequestTransform(rt =>
|
||
{
|
||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
||
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token);
|
||
return ValueTask.CompletedTask;
|
||
});
|
||
});
|
||
|
||
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
||
builder.Services.AddSingleton<ConfigStore>();
|
||
|
||
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||
|
||
var app = builder.Build();
|
||
|
||
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
||
_ = app.Services.GetRequiredService<JwtIssuer>();
|
||
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||
// 主动实例化 SimpleLiteLauncher,让 ProcessExit 钩子尽早注册(MiGu.Server 异常退出时 SimpleLite 也会被清理)。
|
||
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
||
{
|
||
var sl = simpleLiteLauncher.GetDiagnostics();
|
||
app.Logger.LogInformation(
|
||
"[MiGu.Server] SimpleLite: Enabled={Enabled}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
|
||
sl.Enabled, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
|
||
sl.ProjectionPort, sl.ProjectionPortReachable);
|
||
}
|
||
|
||
// MiGu.Server 停机时是否带走 SimpleLite,由 SimpleLiteLauncher.Dispose 内部按 FollowParent 决定:
|
||
// - 会话 N+2 起 FollowParent=false 默认值 → Dispose 仅释放本地引用,不 kill 子进程(独立程序语义);
|
||
// - 仅当用户显式 opt-in FollowParent=true 时,Dispose 才会 kill 子进程 + 关闭 JobObject。
|
||
app.Lifetime.ApplicationStopping.Register(() =>
|
||
{
|
||
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
|
||
catch { /* shutdown best-effort */ }
|
||
});
|
||
|
||
if (app.Environment.IsDevelopment())
|
||
{
|
||
app.UseSwagger();
|
||
app.UseSwaggerUI();
|
||
}
|
||
|
||
app.UseCors();
|
||
|
||
// 静态资源:MiGu.Server/wwwroot 下可同时存放 admin / monitor / index 三份 SPA
|
||
app.UseDefaultFiles();
|
||
app.UseStaticFiles();
|
||
|
||
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
|
||
app.MapControllers();
|
||
|
||
// YARP:/api/sl/* → :8222;/vr/* → :8223
|
||
app.MapReverseProxy();
|
||
|
||
// SPA fallback:单一合并 Vue 工程(vue-router 处理 /admin/* /monitor/* /login /status)
|
||
// 所有非 API、非静态资源的路径都返回 wwwroot/index.html
|
||
app.MapFallbackToFile("index.html");
|
||
|
||
app.Run();
|