第一阶段迁移已完成。
已实现: 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:
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Eis.Application</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eis.Domain\Eis.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
using Eis.Domain.Migration;
|
||||
|
||||
namespace Eis.Application.Migration;
|
||||
|
||||
public sealed record MigrationFeature(FeatureArea Area, bool Native, string RoutePrefix);
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Eis.Domain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Eis.Domain.Migration;
|
||||
|
||||
/// <summary>
|
||||
/// Stable feature boundaries used while replacing the legacy implementation.
|
||||
/// </summary>
|
||||
public enum FeatureArea
|
||||
{
|
||||
Public,
|
||||
Authentication,
|
||||
Candidate,
|
||||
Administration,
|
||||
Admission,
|
||||
Documents,
|
||||
Excel,
|
||||
Caching
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Eis.Infrastructure</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eis.Application\Eis.Application.csproj" />
|
||||
<ProjectReference Include="..\Eis.Domain\Eis.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Eis.Application.Migration;
|
||||
using Eis.Domain.Migration;
|
||||
|
||||
namespace Eis.Infrastructure.Migration;
|
||||
|
||||
public static class MigrationFeatureCatalog
|
||||
{
|
||||
public static IReadOnlyList<MigrationFeature> 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")
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"LegacyNode": {
|
||||
"Enabled": true,
|
||||
"BaseUrl": "http://127.0.0.1:4174"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user