第一阶段迁移已完成。
已实现: 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:
@@ -7,3 +7,7 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.env.docker
|
.env.docker
|
||||||
*.log
|
*.log
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
.vs/
|
||||||
|
artifacts/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Eis.Application/Eis.Application.csproj" />
|
||||||
|
<Project Path="src/Eis.Domain/Eis.Domain.csproj" />
|
||||||
|
<Project Path="src/Eis.Infrastructure/Eis.Infrastructure.csproj" />
|
||||||
|
<Project Path="src/Eis.Web/Eis.Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器仍访问 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
|
- `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
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.302",
|
||||||
|
"rollForward": "latestPatch",
|
||||||
|
"allowPrerelease": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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