feat: 完成v1.3.0架构对齐与Web端根路径托管全量开发

- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐
- 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转
- 实现种子数据初始化:添加默认管理员账号自动创建逻辑
- 优化前端代理与构建:新增Vite代理配置与自动构建Target
- 补全测试与文档:新增集成测试,更新开发与部署文档
This commit is contained in:
ShaoHua
2026-08-08 07:33:44 +08:00
parent 4fe0b5a963
commit 3213d4c718
32 changed files with 1937 additions and 86 deletions
@@ -0,0 +1,155 @@
using System.Net;
using System.Net.Http.Json;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// Host HTTP 管道行为测试。
/// 针对用户反馈的「前端云同步保存并探测报 Request failed with status code 307」。
///
/// 正确探测链路(嵌入式模式 MAUI / Avalonia):
/// 前端 POST /api/cloud-sync/probe {targetUrl}
/// → Vite proxy (target=http://localhost:5057)
/// → MAUI / Avalonia EmbeddedWebServer
/// → CloudSyncProxyMiddleware / CloudProbeService
/// → 后端 HttpClient 探测 {targetUrl}/api
/// 307 若出现,来自前端请求本身被某一层 HTTPS 重定向(Host 侧 UseHttpsRedirection),
/// 而非探测响应中的 HTTP 状态码。
///
/// 纯 Host 独立模式(VITE_API_TARGET 指向 Host)也必须保证匿名端点 HTTP 下不 307。
/// 因此 Host Program.cs 在开发环境不启用 UseHttpsRedirection。
/// </summary>
public sealed class HostHttpPipelineAnonymousEndpointTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly HttpClient _clientDev; // 开发环境(修复后:不启用 UseHttpsRedirection)
private readonly HttpClient _clientProd; // 生产环境(显式启用 UseHttpsRedirection,仅回归验证)
private readonly WebApplication _appDev;
private readonly WebApplication _appProd;
public HostHttpPipelineAnonymousEndpointTests()
{
_connection = new SqliteConnection("Data Source=HostHttpPipelineTests;Mode=Memory;Cache=Shared");
_connection.Open();
_appDev = BuildHost(environmentName: "Development", enableHttpsRedirection: false);
_appProd = BuildHost(environmentName: "Production", enableHttpsRedirection: true);
_clientDev = new HttpClient { BaseAddress = new Uri(_appDev.Urls.First()) };
_clientProd = new HttpClient { BaseAddress = new Uri(_appProd.Urls.First()) };
// 自动跟随重定向会隐藏 307,手动关掉以捕获原始状态码
_clientDev = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{ BaseAddress = new Uri(_appDev.Urls.First()) };
_clientProd = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{ BaseAddress = new Uri(_appProd.Urls.First()) };
}
private WebApplication BuildHost(string environmentName, bool enableHttpsRedirection)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.EnvironmentName = environmentName;
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddApplicationServices("Data Source=HostHttpPipelineTests;Mode=Memory;Cache=Shared");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
// 与修复后的 Program.cs 逻辑一致:仅非开发环境启用 HTTPS 重定向
if (enableHttpsRedirection)
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
app.StartAsync().GetAwaiter().GetResult();
return app;
}
// ===== 开发环境(修复后):匿名端点 HTTP 下不得重定向 =====
[Fact]
public async Task DevEnv_ProbeEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_AuthLoginEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/auth/login",
new { userName = "admin", password = "x" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_AuthBootstrapEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/auth/bootstrap",
new { userName = "admin", password = "Admin@123" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_CloudSyncProxySettingsEndpoint_Http_NoRedirect()
{
var response = await _clientDev.GetAsync("/api/cloud-sync-proxy-settings");
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
// ===== 生产环境:启用 HTTPS 重定向,则 307(回归测试) =====
[Fact]
public async Task ProdEnv_ProbeEndpoint_Http_Returns307TemporaryRedirect()
{
var response = await _clientProd.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
// 生产启用 HTTPS 重定向 → HTTP POST 应触发 307。部署时由反向代理处理,此测试仅保证行为可预测。
Assert.Equal(HttpStatusCode.TemporaryRedirect, response.StatusCode);
}
public void Dispose()
{
_clientDev.Dispose();
_clientProd.Dispose();
foreach (var app in new[] { _appDev, _appProd })
{
app.StopAsync().GetAwaiter().GetResult();
app.DisposeAsync().GetAwaiter().GetResult();
}
_connection.Dispose();
}
private static bool IsRedirect(HttpStatusCode code) =>
(int)code is >= 300 and < 400;
}