本轮迁移已完成,生产运行链路现已切换为纯 ASP.NET Core 10。

主要完成:
补齐招生学校计划、投档审核、报到、扫码、补录等原生接口。
新增 SQLite/MySQL 空库初始化及默认审批流、号码规则。
删除 Node 兼容代理,未知 API 直接返回原生 404。
Docker、Compose、Gitea CI 全部切换到 Eis.Web.dll。
CKEditor 已固化到 Web 发布资源,不再依赖 node_modules。
新增纯 .NET 冒烟脚本:[smoke-dotnet-native.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/smoke-dotnet-native.ps1:1)。
迁移状态已更新:[MIGRATION.md (line 14)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:14)。
容器入口见 [Dockerfile (line 31)](C:/Users/BI/Documents/EIS-dotnet/Dockerfile:31)。
This commit is contained in:
2026-07-23 15:14:42 +08:00 Unverified
parent fea91a19af
commit e3c04dad8a
36 changed files with 1449 additions and 491 deletions
@@ -222,6 +222,15 @@ public static class NativeAdminReadEndpoints
if (options.NativeAdmissionsEnabled)
{
endpoints.MapGet("/api/admission/context",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolContextAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admission/plans",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolPlansAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admission/plans",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolPlanAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admission/notice-template",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolNoticeTemplateAsync(Token(context), cancellationToken)));
@@ -248,6 +257,21 @@ public static class NativeAdminReadEndpoints
context.Request.Query["examId"].ToString(),
await ReadWorkbookAsync(context.Request, cancellationToken),
cancellationToken)));
endpoints.MapPut("/api/admission/reporting/draft",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingDraftAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/scan-preview",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.PreviewAdmissionSchoolReportingScanAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/scan",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingScanAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/submit",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SubmitAdmissionSchoolReportingAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/decision",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingDecisionAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admission/placements",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolPlacementsAsync(Token(context), cancellationToken)));
@@ -259,6 +283,12 @@ public static class NativeAdminReadEndpoints
Token(context),
context.Request.Query["examId"].ToString(),
cancellationToken)));
endpoints.MapPost("/api/admission/placements/bulk",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAdmissionSchoolPlacementsAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admission/placements/{placementId}",
(HttpContext context, string placementId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAdmissionSchoolPlacementAsync(Token(context), placementId, body, cancellationToken)));
endpoints.MapGet("/api/admin/admissions",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAsync(Token(context), cancellationToken)));
-3
View File
@@ -15,8 +15,5 @@
<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>
+3 -20
View File
@@ -1,4 +1,3 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.StaticFiles;
namespace Eis.Web.Frontend;
@@ -22,11 +21,12 @@ public static class FrontendAssets
public static void MapFrontendAssets(this WebApplication app)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
var repositoryRoot = FindRepositoryRoot(app.Environment.ContentRootPath);
if (repositoryRoot is null)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
app.MapFallbackToFile("index.html");
return;
}
@@ -42,23 +42,6 @@ public static class FrontendAssets
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");
-135
View File
@@ -1,135 +0,0 @@
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
@@ -1,10 +0,0 @@
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");
}
+14 -25
View File
@@ -1,4 +1,3 @@
using System.Net;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
@@ -13,7 +12,6 @@ using Eis.Web.Authentication;
using Eis.Web.Administration;
using Eis.Web.Candidate;
using Eis.Web.Frontend;
using Eis.Web.Legacy;
using Eis.Web.Public;
var applicationRoot = ApplicationPaths.FindApplicationRoot();
@@ -21,19 +19,6 @@ EnvironmentFile.Load(applicationRoot);
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();
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
var authenticationOptions = AuthenticationOptions.FromEnvironment(
@@ -68,6 +53,7 @@ builder.Services.AddEisInfrastructure(
adminMigrationOptions);
var app = builder.Build();
await app.Services.InitializeEisDatabaseAsync();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
app.UseExceptionHandler();
@@ -86,17 +72,13 @@ app.MapGet("/health/live", () => Results.Json(new
framework = ".NET 10"
}));
app.MapGet("/health/migration", async (
LegacyApiProxy proxy,
IApplicationCache cache,
CancellationToken cancellationToken) =>
app.MapGet("/health/migration", (IApplicationCache cache) =>
{
var legacyAvailable = await proxy.IsAvailableAsync(cancellationToken);
var statusCode = legacyAvailable ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
return Results.Json(new
{
status = legacyAvailable ? "healthy" : "degraded",
legacyApiAvailable = legacyAvailable,
status = "healthy",
legacyApiAvailable = false,
legacyApiRemoved = true,
cache = new
{
status = cache.Status,
@@ -202,7 +184,7 @@ app.MapGet("/health/migration", async (
.ToArray()
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
}, statusCode: statusCode);
}, statusCode: StatusCodes.Status200OK);
});
app.MapNativePublicEndpoints();
@@ -220,7 +202,14 @@ string[] methods =
HttpMethods.Delete,
HttpMethods.Options
];
app.MapMethods("/api/{**path}", methods, (HttpContext context, LegacyApiProxy proxy) => proxy.ForwardAsync(context));
app.MapMethods("/api/{**path}", methods, (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
return Results.Json(
new { ok = false, message = "API 接口不存在" },
statusCode: StatusCodes.Status404NotFound);
});
app.MapFrontendAssets();
+16 -20
View File
@@ -1,29 +1,25 @@
{
"LegacyNode": {
"Enabled": true,
"BaseUrl": "http://127.0.0.1:4174"
},
"AuthenticationMigration": {
"NativeEnabled": false
"NativeEnabled": true
},
"CandidateMigration": {
"NativeEnabled": false
"NativeEnabled": true
},
"AdminMigration": {
"NativeReadsEnabled": false,
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false,
"NativeNoticeManagementEnabled": false,
"NativeCentersEnabled": false,
"NativeOperationalReadsEnabled": false,
"NativeCandidateManagementEnabled": false,
"NativeRegistrationPaymentWritesEnabled": false,
"NativeWorkflowOperationsEnabled": false,
"NativeExamManagementEnabled": false,
"NativeArrangementsEnabled": false,
"NativeResultsEnabled": false,
"NativeAdmissionsEnabled": false
"NativeReadsEnabled": true,
"NativeOrganizationWritesEnabled": true,
"NativeAccountBatchesEnabled": true,
"NativeConfigurationEnabled": true,
"NativeNoticeManagementEnabled": true,
"NativeCentersEnabled": true,
"NativeOperationalReadsEnabled": true,
"NativeCandidateManagementEnabled": true,
"NativeRegistrationPaymentWritesEnabled": true,
"NativeWorkflowOperationsEnabled": true,
"NativeExamManagementEnabled": true,
"NativeArrangementsEnabled": true,
"NativeResultsEnabled": true,
"NativeAdmissionsEnabled": true
},
"Logging": {
"LogLevel": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long