diff --git a/.gitignore b/.gitignore
index d316597..8349bd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,7 @@ node_modules/
.env
.env.docker
*.log
+**/bin/
+**/obj/
+.vs/
+artifacts/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..0c8052e
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ latest
+ enable
+ enable
+ true
+ latest
+
+
diff --git a/Eis.slnx b/Eis.slnx
new file mode 100644
index 0000000..940e8a1
--- /dev/null
+++ b/Eis.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 0000000..c37e0d0
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,45 @@
+# ASP.NET Core 10 迁移
+
+迁移采用兼容优先的渐进式方案:ASP.NET Core 作为统一入口,尚未迁移的 `/api/*` 请求暂时转发给运行在 `4174` 端口的 Node.js 服务。每完成一个功能域,就在 ASP.NET Core 中注册对应原生端点并停止转发该路径。
+
+## 当前阶段
+
+- [x] ASP.NET Core 10 解决方案与分层项目
+- [x] 原前端静态资源无修改托管
+- [x] 旧 API 兼容转发,包含 Cookie、请求体、文件下载和状态码
+- [x] 存活与迁移就绪检查
+- [ ] 公开接口与数据库读取
+- [ ] 登录、Session 与 TOTP
+- [ ] 考生业务
+- [ ] 管理后台、审批流和考务编排
+- [ ] 招生录取
+- [ ] Excel、文书和缓存
+- [ ] 容器入口切换及 Node.js 后端移除
+
+## 本地运行
+
+先在一个终端运行旧 API:
+
+```powershell
+$env:PORT = '4174'
+npm start
+```
+
+再在另一个终端运行 ASP.NET Core 入口:
+
+```powershell
+dotnet run --project .\src\Eis.Web\Eis.Web.csproj
+```
+
+浏览器仍访问 。
+
+- `GET /health/live`:只检查 ASP.NET Core 宿主。
+- `GET /health/migration`:检查迁移宿主和旧 API 转发链路。
+
+可通过配置 `LegacyNode:Enabled=false` 禁用兼容转发;此时尚未迁移的 API 会返回 `501`。
+
+完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
+
+```powershell
+pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-migration.ps1
+```
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..6d5813f
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.302",
+ "rollForward": "latestPatch",
+ "allowPrerelease": false
+ }
+}
diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1
new file mode 100644
index 0000000..0260df2
--- /dev/null
+++ b/scripts/smoke-dotnet-migration.ps1
@@ -0,0 +1,182 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
+$testDirectory = Join-Path $temporaryRoot ("eis-migration-smoke-{0}" -f [guid]::NewGuid().ToString('N'))
+$nodeProcess = $null
+$dotnetProcess = $null
+
+function Get-AvailableTcpPort {
+ $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
+ try {
+ $listener.Start()
+ return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port
+ }
+ finally {
+ $listener.Stop()
+ }
+}
+
+function Start-TestProcess {
+ param(
+ [Parameter(Mandatory)]
+ [string] $FileName,
+
+ [Parameter(Mandatory)]
+ [string[]] $ArgumentList,
+
+ [Parameter(Mandatory)]
+ [hashtable] $Environment
+ )
+
+ $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = $FileName
+ $startInfo.WorkingDirectory = $repositoryRoot
+ $startInfo.UseShellExecute = $false
+ $startInfo.CreateNoWindow = $true
+ $startInfo.RedirectStandardOutput = $true
+ $startInfo.RedirectStandardError = $true
+ foreach ($argument in $ArgumentList) {
+ $startInfo.ArgumentList.Add($argument)
+ }
+ foreach ($entry in $Environment.GetEnumerator()) {
+ $startInfo.Environment[$entry.Key] = [string] $entry.Value
+ }
+
+ return [System.Diagnostics.Process]::Start($startInfo)
+}
+
+function Wait-ForUrl {
+ param(
+ [Parameter(Mandatory)]
+ [uri] $Uri,
+
+ [Parameter(Mandatory)]
+ [System.Diagnostics.Process[]] $Processes
+ )
+
+ $deadline = [DateTimeOffset]::UtcNow.AddSeconds(25)
+ while ([DateTimeOffset]::UtcNow -lt $deadline) {
+ foreach ($process in $Processes) {
+ if ($process.HasExited) {
+ $standardOutput = $process.StandardOutput.ReadToEnd()
+ $standardError = $process.StandardError.ReadToEnd()
+ throw "Process $($process.Id) exited with code $($process.ExitCode) before $Uri became ready`n$standardOutput`n$standardError"
+ }
+ }
+
+ try {
+ $response = Invoke-WebRequest -Uri $Uri -TimeoutSec 2
+ if ($response.StatusCode -eq 200) {
+ return
+ }
+ }
+ catch {
+ Start-Sleep -Milliseconds 250
+ }
+ }
+
+ throw "Timed out waiting for $Uri"
+}
+
+try {
+ New-Item -ItemType Directory -Path $testDirectory | Out-Null
+
+ $legacyPort = Get-AvailableTcpPort
+ $webPort = Get-AvailableTcpPort
+ $legacyBaseUrl = "http://127.0.0.1:$legacyPort"
+ $webBaseUrl = "http://127.0.0.1:$webPort"
+
+ $nodeExecutable = (Get-Command node.exe -ErrorAction Stop).Source
+ $dotnetExecutable = (Get-Command dotnet.exe -ErrorAction Stop).Source
+ $nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment @{
+ NODE_ENV = 'test'
+ HOST = '127.0.0.1'
+ PORT = [string] $legacyPort
+ DATABASE_CLIENT = 'sqlite'
+ SQLITE_PATH = (Join-Path $testDirectory 'smoke.sqlite')
+ INITIAL_ADMIN_USERNAME = 'migration_admin'
+ INITIAL_ADMIN_PASSWORD = 'Migration123!'
+ INITIAL_ADMIN_DISPLAY_NAME = '迁移测试管理员'
+ REDIS_URL = ''
+ REDIS_SESSION_URL = ''
+ TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum'
+ DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
+ }
+
+ $projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
+ $dotnetProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
+ 'run',
+ '--project', $projectPath,
+ '--configuration', 'Release',
+ '--no-build',
+ '--no-launch-profile',
+ '--',
+ '--urls', $webBaseUrl
+ ) -Environment @{
+ ASPNETCORE_ENVIRONMENT = 'Development'
+ LegacyNode__Enabled = 'true'
+ LegacyNode__BaseUrl = $legacyBaseUrl
+ }
+
+ Wait-ForUrl -Uri "$webBaseUrl/health/live" -Processes @($nodeProcess, $dotnetProcess)
+ Wait-ForUrl -Uri "$webBaseUrl/health/migration" -Processes @($nodeProcess, $dotnetProcess)
+
+ $index = Invoke-WebRequest -Uri "$webBaseUrl/"
+ if ($index.Content -notmatch '衡准') {
+ throw 'ASP.NET Core did not serve the existing index.html'
+ }
+
+ $clientModule = Invoke-WebRequest -Uri "$webBaseUrl/src/client/api.mjs"
+ if ($clientModule.Content -notmatch 'export function api') {
+ throw 'ASP.NET Core did not serve the existing client module'
+ }
+
+ $homePayload = Invoke-RestMethod -Uri "$webBaseUrl/api/public/home"
+ if ($homePayload.ok -ne $true) {
+ throw 'Legacy public API proxy did not preserve the JSON response'
+ }
+
+ $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
+ $loginBody = @{ username = 'migration_admin'; password = 'Migration123!' } | ConvertTo-Json -Compress
+ $login = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $session
+ if ($login.ok -ne $true -or $login.user.username -ne 'migration_admin') {
+ throw 'Legacy login API proxy did not preserve the response'
+ }
+
+ $currentUser = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/me" -WebSession $session
+ if ($currentUser.user.username -ne 'migration_admin') {
+ throw 'Legacy API proxy did not preserve the session cookie'
+ }
+
+ [pscustomobject]@{
+ AspNetCoreHost = 'passed'
+ StaticAssets = 'passed'
+ JsonProxy = 'passed'
+ SessionCookie = 'passed'
+ } | Format-List
+}
+finally {
+ foreach ($process in @($dotnetProcess, $nodeProcess)) {
+ if ($null -ne $process -and -not $process.HasExited) {
+ $process.Kill($true)
+ $process.WaitForExit()
+ }
+ if ($null -ne $process) {
+ $process.Dispose()
+ }
+ }
+
+ if (Test-Path -LiteralPath $testDirectory) {
+ $resolvedTestDirectory = [System.IO.Path]::GetFullPath($testDirectory)
+ if (-not $resolvedTestDirectory.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase) -or
+ -not ([System.IO.Path]::GetFileName($resolvedTestDirectory)).StartsWith('eis-migration-smoke-', [StringComparison]::Ordinal)) {
+ throw "Refusing to remove unexpected smoke-test directory: $resolvedTestDirectory"
+ }
+ Remove-Item -LiteralPath $resolvedTestDirectory -Recurse -Force
+ }
+}
diff --git a/src/Eis.Application/Eis.Application.csproj b/src/Eis.Application/Eis.Application.csproj
new file mode 100644
index 0000000..3f03e4a
--- /dev/null
+++ b/src/Eis.Application/Eis.Application.csproj
@@ -0,0 +1,9 @@
+
+
+ net10.0
+ Eis.Application
+
+
+
+
+
diff --git a/src/Eis.Application/Migration/MigrationFeature.cs b/src/Eis.Application/Migration/MigrationFeature.cs
new file mode 100644
index 0000000..450a9f7
--- /dev/null
+++ b/src/Eis.Application/Migration/MigrationFeature.cs
@@ -0,0 +1,5 @@
+using Eis.Domain.Migration;
+
+namespace Eis.Application.Migration;
+
+public sealed record MigrationFeature(FeatureArea Area, bool Native, string RoutePrefix);
diff --git a/src/Eis.Domain/Eis.Domain.csproj b/src/Eis.Domain/Eis.Domain.csproj
new file mode 100644
index 0000000..e7b43dd
--- /dev/null
+++ b/src/Eis.Domain/Eis.Domain.csproj
@@ -0,0 +1,6 @@
+
+
+ net10.0
+ Eis.Domain
+
+
diff --git a/src/Eis.Domain/Migration/FeatureArea.cs b/src/Eis.Domain/Migration/FeatureArea.cs
new file mode 100644
index 0000000..af4f8f0
--- /dev/null
+++ b/src/Eis.Domain/Migration/FeatureArea.cs
@@ -0,0 +1,16 @@
+namespace Eis.Domain.Migration;
+
+///
+/// Stable feature boundaries used while replacing the legacy implementation.
+///
+public enum FeatureArea
+{
+ Public,
+ Authentication,
+ Candidate,
+ Administration,
+ Admission,
+ Documents,
+ Excel,
+ Caching
+}
diff --git a/src/Eis.Infrastructure/Eis.Infrastructure.csproj b/src/Eis.Infrastructure/Eis.Infrastructure.csproj
new file mode 100644
index 0000000..7dcd91a
--- /dev/null
+++ b/src/Eis.Infrastructure/Eis.Infrastructure.csproj
@@ -0,0 +1,10 @@
+
+
+ net10.0
+ Eis.Infrastructure
+
+
+
+
+
+
diff --git a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs
new file mode 100644
index 0000000..d84571a
--- /dev/null
+++ b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs
@@ -0,0 +1,19 @@
+using Eis.Application.Migration;
+using Eis.Domain.Migration;
+
+namespace Eis.Infrastructure.Migration;
+
+public static class MigrationFeatureCatalog
+{
+ public static IReadOnlyList Current { get; } =
+ [
+ new(FeatureArea.Public, false, "/api/public"),
+ new(FeatureArea.Authentication, false, "/api/auth"),
+ new(FeatureArea.Candidate, false, "/api/candidate"),
+ new(FeatureArea.Administration, false, "/api/admin"),
+ new(FeatureArea.Admission, false, "/api/admission"),
+ new(FeatureArea.Documents, false, "/api"),
+ new(FeatureArea.Excel, false, "/api"),
+ new(FeatureArea.Caching, false, "/api")
+ ];
+}
diff --git a/src/Eis.Web/Eis.Web.csproj b/src/Eis.Web/Eis.Web.csproj
new file mode 100644
index 0000000..92c60fd
--- /dev/null
+++ b/src/Eis.Web/Eis.Web.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ Eis.Web
+ Eis.Web
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Eis.Web/Frontend/FrontendAssets.cs b/src/Eis.Web/Frontend/FrontendAssets.cs
new file mode 100644
index 0000000..360b21a
--- /dev/null
+++ b/src/Eis.Web/Frontend/FrontendAssets.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.FileProviders;
+using Microsoft.AspNetCore.StaticFiles;
+
+namespace Eis.Web.Frontend;
+
+public static class FrontendAssets
+{
+ private static readonly string[] ClientModules =
+ [
+ "api.mjs",
+ "admin-views.mjs",
+ "candidate-views.mjs",
+ "admission-views.mjs",
+ "admission-plan-editor.mjs",
+ "public-views.mjs",
+ "state.mjs",
+ "ui.mjs",
+ "table-state.mjs",
+ "pdf-export.mjs",
+ "region-select.mjs"
+ ];
+
+ public static void MapFrontendAssets(this WebApplication app)
+ {
+ var repositoryRoot = FindRepositoryRoot(app.Environment.ContentRootPath);
+ if (repositoryRoot is null)
+ {
+ app.UseDefaultFiles();
+ app.UseStaticFiles(CreateStaticOptions());
+ app.MapFallbackToFile("index.html");
+ return;
+ }
+
+ var clientRoot = Path.Combine(repositoryRoot, "src", "client");
+ foreach (var fileName in ClientModules)
+ {
+ MapFile(app, $"/src/client/{fileName}", Path.Combine(clientRoot, fileName), "text/javascript; charset=utf-8");
+ }
+
+ MapFile(app, "/src/data/china-regions.mjs", Path.Combine(repositoryRoot, "src", "data", "china-regions.mjs"), "text/javascript; charset=utf-8");
+ MapFile(app, "/src/data/specialty-types.mjs", Path.Combine(repositoryRoot, "src", "data", "specialty-types.mjs"), "text/javascript; charset=utf-8");
+ MapFile(app, "/styles.css", Path.Combine(repositoryRoot, "styles.css"), "text/css; charset=utf-8");
+ MapFile(app, "/app.js", Path.Combine(repositoryRoot, "app.js"), "text/javascript; charset=utf-8");
+
+ var ckeditorRoot = Path.Combine(repositoryRoot, "node_modules", "ckeditor5", "dist");
+ if (Directory.Exists(ckeditorRoot))
+ {
+ app.UseStaticFiles(new StaticFileOptions
+ {
+ FileProvider = new PhysicalFileProvider(Path.Combine(ckeditorRoot, "browser")),
+ RequestPath = "/vendor/ckeditor5",
+ OnPrepareResponse = SetNoCache
+ });
+ app.UseStaticFiles(new StaticFileOptions
+ {
+ FileProvider = new PhysicalFileProvider(Path.Combine(ckeditorRoot, "translations")),
+ RequestPath = "/vendor/ckeditor5/translations",
+ OnPrepareResponse = SetNoCache
+ });
+ }
+
+ var indexPath = Path.Combine(repositoryRoot, "index.html");
+ MapFile(app, "/", indexPath, "text/html; charset=utf-8");
+ MapFile(app, "/index.html", indexPath, "text/html; charset=utf-8");
+ app.MapFallback(async context =>
+ {
+ context.Response.ContentType = "text/html; charset=utf-8";
+ context.Response.Headers.CacheControl = "no-cache";
+ await context.Response.SendFileAsync(indexPath, context.RequestAborted);
+ });
+ }
+
+ private static void MapFile(WebApplication app, string route, string path, string contentType) =>
+ app.MapGet(route, (HttpContext context) =>
+ {
+ context.Response.Headers.CacheControl = "no-cache";
+ return Results.File(path, contentType);
+ });
+
+ private static StaticFileOptions CreateStaticOptions() => new()
+ {
+ OnPrepareResponse = SetNoCache
+ };
+
+ private static void SetNoCache(StaticFileResponseContext context) =>
+ context.Context.Response.Headers.CacheControl = "no-cache";
+
+ private static string? FindRepositoryRoot(string contentRoot)
+ {
+ var candidate = Path.GetFullPath(Path.Combine(contentRoot, "..", ".."));
+ return File.Exists(Path.Combine(candidate, "package.json")) && File.Exists(Path.Combine(candidate, "index.html"))
+ ? candidate
+ : null;
+ }
+}
diff --git a/src/Eis.Web/Legacy/LegacyApiProxy.cs b/src/Eis.Web/Legacy/LegacyApiProxy.cs
new file mode 100644
index 0000000..3b23515
--- /dev/null
+++ b/src/Eis.Web/Legacy/LegacyApiProxy.cs
@@ -0,0 +1,135 @@
+using System.Net;
+using System.Net.Http.Headers;
+using Microsoft.Extensions.Options;
+
+namespace Eis.Web.Legacy;
+
+public sealed class LegacyApiProxy(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger)
+{
+ private static readonly HashSet HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Connection",
+ "Keep-Alive",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ "TE",
+ "Trailer",
+ "Transfer-Encoding",
+ "Upgrade"
+ };
+
+ private readonly LegacyNodeOptions _options = options.Value;
+
+ public async Task ForwardAsync(HttpContext context)
+ {
+ if (!_options.Enabled)
+ {
+ context.Response.StatusCode = StatusCodes.Status501NotImplemented;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ ok = false,
+ message = "该接口尚未迁移到 ASP.NET Core",
+ migration = new { native = false, legacyProxyEnabled = false }
+ }, context.RequestAborted);
+ return;
+ }
+
+ var target = new Uri(_options.BaseUrl, $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}");
+ using var outbound = CreateRequest(context, target);
+
+ try
+ {
+ using var upstream = await httpClient.SendAsync(
+ outbound,
+ HttpCompletionOption.ResponseHeadersRead,
+ context.RequestAborted);
+
+ context.Response.StatusCode = (int)upstream.StatusCode;
+ CopyResponseHeaders(upstream, context.Response);
+
+ if (context.Request.Method != HttpMethods.Head && upstream.StatusCode != HttpStatusCode.NoContent)
+ {
+ await upstream.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
+ }
+ }
+ catch (HttpRequestException exception)
+ {
+ logger.LogWarning(exception, "Legacy Node API at {Target} is unavailable", target);
+ if (context.Response.HasStarted)
+ {
+ context.Abort();
+ return;
+ }
+
+ context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ ok = false,
+ message = "迁移期间的旧版 API 服务暂时不可用",
+ migration = new { native = false, legacyProxyEnabled = true }
+ }, context.RequestAborted);
+ }
+ }
+
+ public async Task IsAvailableAsync(CancellationToken cancellationToken)
+ {
+ if (!_options.Enabled)
+ {
+ return true;
+ }
+
+ try
+ {
+ using var response = await httpClient.GetAsync("api/public/home", cancellationToken);
+ return response.IsSuccessStatusCode;
+ }
+ catch (HttpRequestException)
+ {
+ return false;
+ }
+ }
+
+ private static HttpRequestMessage CreateRequest(HttpContext context, Uri target)
+ {
+ var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), target);
+ var hasBody = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0;
+ if (hasBody)
+ {
+ request.Content = new StreamContent(context.Request.Body);
+ }
+
+ foreach (var (name, values) in context.Request.Headers)
+ {
+ if (name.Equals("Host", StringComparison.OrdinalIgnoreCase) || HopByHopHeaders.Contains(name))
+ {
+ continue;
+ }
+
+ var valueArray = values.ToArray();
+ if (!request.Headers.TryAddWithoutValidation(name, valueArray) && request.Content is not null)
+ {
+ request.Content.Headers.TryAddWithoutValidation(name, valueArray);
+ }
+ }
+
+ request.Headers.TryAddWithoutValidation("X-Forwarded-Host", context.Request.Host.Value);
+ request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", context.Request.Scheme);
+ return request;
+ }
+
+ private static void CopyResponseHeaders(HttpResponseMessage upstream, HttpResponse response)
+ {
+ foreach (var header in upstream.Headers.Concat(upstream.Content.Headers))
+ {
+ if (!HopByHopHeaders.Contains(header.Key))
+ {
+ response.Headers.Append(header.Key, header.Value.ToArray());
+ }
+ }
+
+ response.Headers.Remove("transfer-encoding");
+ }
+}
diff --git a/src/Eis.Web/Legacy/LegacyNodeOptions.cs b/src/Eis.Web/Legacy/LegacyNodeOptions.cs
new file mode 100644
index 0000000..7a79bf2
--- /dev/null
+++ b/src/Eis.Web/Legacy/LegacyNodeOptions.cs
@@ -0,0 +1,10 @@
+namespace Eis.Web.Legacy;
+
+public sealed class LegacyNodeOptions
+{
+ public const string SectionName = "LegacyNode";
+
+ public bool Enabled { get; init; } = true;
+
+ public Uri BaseUrl { get; init; } = new("http://127.0.0.1:4174");
+}
diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs
new file mode 100644
index 0000000..6193737
--- /dev/null
+++ b/src/Eis.Web/Program.cs
@@ -0,0 +1,70 @@
+using System.Net;
+using Eis.Infrastructure.Migration;
+using Eis.Web.Frontend;
+using Eis.Web.Legacy;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
+builder.Services.Configure(builder.Configuration.GetSection(LegacyNodeOptions.SectionName));
+builder.Services.AddHttpClient((services, client) =>
+{
+ var options = services.GetRequiredService>().Value;
+ client.BaseAddress = options.BaseUrl;
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Eis.AspNetCore.Migration/1.0");
+}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
+{
+ AllowAutoRedirect = false,
+ AutomaticDecompression = DecompressionMethods.None,
+ UseCookies = false
+});
+builder.Services.AddProblemDetails();
+
+var app = builder.Build();
+
+app.UseExceptionHandler();
+app.Use(async (context, next) =>
+{
+ context.Response.Headers.XContentTypeOptions = "nosniff";
+ context.Response.Headers.XFrameOptions = "DENY";
+ context.Response.Headers["Referrer-Policy"] = "same-origin";
+ await next();
+});
+
+app.MapGet("/health/live", () => Results.Json(new
+{
+ status = "healthy",
+ service = "Eis.Web",
+ framework = ".NET 10"
+}));
+
+app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken cancellationToken) =>
+{
+ var legacyAvailable = await proxy.IsAvailableAsync(cancellationToken);
+ var statusCode = legacyAvailable ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
+ return Results.Json(new
+ {
+ status = legacyAvailable ? "healthy" : "degraded",
+ legacyApiAvailable = legacyAvailable,
+ features = MigrationFeatureCatalog.Current
+ }, statusCode: statusCode);
+});
+
+string[] methods =
+[
+ HttpMethods.Get,
+ HttpMethods.Head,
+ HttpMethods.Post,
+ HttpMethods.Put,
+ HttpMethods.Patch,
+ HttpMethods.Delete,
+ HttpMethods.Options
+];
+app.MapMethods("/api/{**path}", methods, (HttpContext context, LegacyApiProxy proxy) => proxy.ForwardAsync(context));
+
+app.MapFrontendAssets();
+
+app.Run();
+
+public partial class Program;
diff --git a/src/Eis.Web/Properties/launchSettings.json b/src/Eis.Web/Properties/launchSettings.json
new file mode 100644
index 0000000..fc90fd7
--- /dev/null
+++ b/src/Eis.Web/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://127.0.0.1:4173",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json
new file mode 100644
index 0000000..3750199
--- /dev/null
+++ b/src/Eis.Web/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "LegacyNode": {
+ "Enabled": true,
+ "BaseUrl": "http://127.0.0.1:4174"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/Eis.Web/wwwroot/.gitkeep b/src/Eis.Web/wwwroot/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/Eis.Web/wwwroot/.gitkeep
@@ -0,0 +1 @@
+