第一阶段迁移已完成。

已实现:
ASP.NET Core 10 分层解决方案:[Eis.slnx](C:/Users/BI/Documents/EIS-dotnet/Eis.slnx)
ASP.NET Core 统一入口与健康检查:[Program.cs](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Program.cs)
原前端静态资源无修改托管
未迁移 API 自动转发至旧 Node 服务,兼容 JSON、Cookie、请求体和文件下载
可重复执行的端到端测试:[smoke-dotnet-migration.ps1](C:/Users/BI/Documents/EIS-dotnet/scripts/smoke-dotnet-migration.ps1)
迁移进度与运行说明:[MIGRATION.md](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md)
验证结果:
This commit is contained in:
2026-07-22 18:45:54 +08:00 Unverified
parent ecb3dc63ed
commit 241fa8a6d7
20 changed files with 680 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Eis.Web</RootNamespace>
<AssemblyName>Eis.Web</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Eis.Application\Eis.Application.csproj" />
<ProjectReference Include="..\Eis.Infrastructure\Eis.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\index.html" Link="wwwroot\index.html" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\styles.css" Link="wwwroot\styles.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\app.js" Link="wwwroot\app.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\client\**\*.mjs" Link="wwwroot\src\client\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\china-regions.mjs" Link="wwwroot\src\data\china-regions.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\specialty-types.mjs" Link="wwwroot\src\data\specialty-types.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\node_modules\ckeditor5\dist\browser\ckeditor5.js" Link="wwwroot\vendor\ckeditor5\ckeditor5.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\browser\ckeditor5.js')" />
<Content Include="..\..\node_modules\ckeditor5\dist\browser\ckeditor5.css" Link="wwwroot\vendor\ckeditor5\ckeditor5.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\browser\ckeditor5.css')" />
<Content Include="..\..\node_modules\ckeditor5\dist\translations\zh-cn.js" Link="wwwroot\vendor\ckeditor5\translations\zh-cn.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\translations\zh-cn.js')" />
</ItemGroup>
</Project>
+95
View File
@@ -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;
}
}
+135
View File
@@ -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<LegacyNodeOptions> options,
ILogger<LegacyApiProxy> logger)
{
private static readonly HashSet<string> 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<bool> 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");
}
}
+10
View File
@@ -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");
}
+70
View File
@@ -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<LegacyNodeOptions>(builder.Configuration.GetSection(LegacyNodeOptions.SectionName));
builder.Services.AddHttpClient<LegacyApiProxy>((services, client) =>
{
var options = services.GetRequiredService<Microsoft.Extensions.Options.IOptions<LegacyNodeOptions>>().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;
@@ -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"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"LegacyNode": {
"Enabled": true,
"BaseUrl": "http://127.0.0.1:4174"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+1
View File
@@ -0,0 +1 @@