feat: 完成云同步、语音控制与多平台扩展基础架构搭建

本次提交完成了项目核心基础架构升级:
1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离
2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块
3. 实现语音控制全链路,从STT/意图解析到命令执行
4. 新增任务类型、附件实体与相关仓储接口
5. 重构前端配置与代理规则,统一后端端口为5057
6. 新增多平台测试项目与CI脚本优化
7. 完善项目文档与代码注释规范

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
This commit is contained in:
ShaoHua
2026-06-21 03:26:04 +08:00
parent 65cee20006
commit 4fe0b5a963
200 changed files with 12728 additions and 3550 deletions
@@ -0,0 +1,94 @@
using System.Net;
using System.Net.Http.Json;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Hua.Todo.Maui.Tests.CloudSync;
/// <summary>
/// CloudSyncProxyMiddleware 行为集成测试(MAUI 端)。
/// 验证云同步路径代理转发、无 URL 时返回 503。
/// </summary>
public class CloudSyncProxyMiddlewareTests : IDisposable
{
private readonly WebApplication _app;
private readonly HttpClient _client;
public CloudSyncProxyMiddlewareTests()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddCloudSyncProxy();
var app = builder.Build();
app.UseCloudSyncProxy();
_app = app;
_app.StartAsync().GetAwaiter().GetResult();
_client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) };
}
[Fact]
public async Task Probe_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe",
new { targetUrl = "http://localhost:5173" });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("cloud sync server URL not configured", content);
}
[Fact]
public async Task AuthLogin_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/auth/login",
new { username = "admin", password = "123456" });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task Tasks_NoCloudSyncUrl_Returns503()
{
var response = await _client.GetAsync("/api/tasks/");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task Tasks_WithoutTrailingSlash_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/tasks", new { });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task SecurityPolicy_NoCloudSyncUrl_Returns503()
{
var response = await _client.GetAsync("/api/security/policy");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task Sync_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/sync/", new { });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
public void Dispose()
{
_client.Dispose();
_app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().GetAwaiter().GetResult();
}
}
@@ -0,0 +1,315 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Hua.Todo.Core.Entities;
using Xunit;
using System.Data.Common;
using Hua.Todo.Application.Repositories;
namespace Hua.Todo.Maui.Tests;
/// <summary>
/// 数据库初始化集成测试,覆盖 MauiProgram.InitializeDatabase 的 Migrate → EnsureCreated 回退链路。
/// 对应日志中 "no such table: T_Tasks" 错误场景,验证迁移链完整性与恢复能力。
/// </summary>
public class DatabaseInitializationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<TodoDbContext> _options;
public DatabaseInitializationTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
}
public void Dispose()
{
_connection.Dispose();
}
private TodoDbContext CreateContext()
{
return new TodoDbContext(_options);
}
/// <summary>
/// 模拟 MauiProgram.InitializeDatabase 的 Migrate() 路径:
/// 从零开始执行全部迁移,验证 T_Tasks 表存在。
/// 对应日志第 1-21 行:Migrate() 失败场景。
/// </summary>
[Fact]
public void InitializeDatabase_Migrate_FromScratch_CreatesTTasksTable()
{
// Arrange - 模拟 MauiProgram.InitializeDatabase 中的 Migrate() 调用
using var context = CreateContext();
// Act: 执行迁移(等同于 Migrate()
context.Database.Migrate();
// Assert: T_Tasks 表存在
var tables = GetTableNames(context);
Assert.Contains("T_Tasks", tables);
}
/// <summary>
/// 验证迁移后所有核心表存在。
/// 对应日志第 23-27 行:查询 T_Tasks 失败场景。
/// </summary>
[Fact]
public void InitializeDatabase_Migrate_AllCoreTablesExist()
{
using var context = CreateContext();
context.Database.Migrate();
var tables = GetTableNames(context);
Assert.Contains("T_Tasks", tables);
Assert.Contains("Attachments", tables);
Assert.Contains("Users", tables);
Assert.Contains("UserSessions", tables);
Assert.Contains("SecurityPolicies", tables);
Assert.Contains("AuditLogs", tables);
}
/// <summary>
/// 验证 T_Tasks 表包含日志中查询的所有列。
/// 对应日志第 24-26 行 SELECT 查询中引用的所有字段。
/// </summary>
[Fact]
public void InitializeDatabase_TTasks_HasAllColumnsFromQuery()
{
using var context = CreateContext();
context.Database.Migrate();
var columns = GetColumnNames(context, "T_Tasks");
// 日志第 24 行 SELECT 查询中引用的字段,必须全部存在
Assert.Contains("Id", columns);
Assert.Contains("AudioDuration", columns);
Assert.Contains("Code", columns);
Assert.Contains("ConcurrencyStamp", columns);
Assert.Contains("CreationTime", columns);
Assert.Contains("CreatorId", columns);
Assert.Contains("DeleterId", columns);
Assert.Contains("DeletionTime", columns);
Assert.Contains("Description", columns);
Assert.Contains("ExtraProperties", columns);
Assert.Contains("IsCompleted", columns);
Assert.Contains("IsDeleted", columns);
Assert.Contains("LastModificationTime", columns);
Assert.Contains("LastModifierId", columns);
Assert.Contains("MeetingNotes", columns);
Assert.Contains("ParentTaskId", columns);
Assert.Contains("Priority", columns);
Assert.Contains("TaskType", columns);
Assert.Contains("Title", columns);
Assert.Contains("UserId", columns);
}
/// <summary>
/// 模拟 MauiProgram.InitializeDatabase 的 fallback 路径:
/// Migrate 失败 → 删除数据库 → EnsureCreated 重建。
/// 对应日志第 1 行 "Database initialization failed" 后的恢复流程。
/// </summary>
[Fact]
public void InitializeDatabase_Fallback_EnsureCreated_AfterFailure()
{
// 使用文件数据库模拟 fallback 中的文件删除操作
var tempDbPath = Path.Combine(Path.GetTempPath(), $"hua_todo_test_{Guid.NewGuid()}.db");
try
{
var connectionString = $"Data Source={tempDbPath}";
// 先创建一个损坏的数据库(有迁移历史但表被删除)
SqliteConnection? conn1 = null;
try
{
conn1 = new SqliteConnection(connectionString);
conn1.Open();
var options1 = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(conn1)
.Options;
using var context1 = new TodoDbContext(options1);
context1.Database.Migrate();
// 手动删除 T_Tasks 模拟损坏
context1.Database.ExecuteSqlRaw("DROP TABLE IF EXISTS T_Tasks;");
}
finally
{
// 显式关闭连接并清空连接池,确保文件句柄释放
conn1?.Close();
conn1?.Dispose();
SqliteConnection.ClearAllPools();
}
// 模拟 InitializeDatabase fallback: 删除 DB 文件,重新创建
if (File.Exists(tempDbPath))
{
File.Delete(tempDbPath);
var walPath = tempDbPath + "-wal";
var shmPath = tempDbPath + "-shm";
if (File.Exists(walPath)) File.Delete(walPath);
if (File.Exists(shmPath)) File.Delete(shmPath);
}
// EnsureCreated: 从当前模型创建 schema
SqliteConnection? conn2 = null;
try
{
conn2 = new SqliteConnection(connectionString);
conn2.Open();
var options2 = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(conn2)
.Options;
using var context2 = new TodoDbContext(options2);
// Act: EnsureCreatedfallback 路径)
context2.Database.EnsureCreated();
// Assert: T_Tasks 已重建
var tables = GetTableNames(context2);
Assert.Contains("T_Tasks", tables);
Assert.Contains("Attachments", tables);
}
finally
{
conn2?.Close();
conn2?.Dispose();
SqliteConnection.ClearAllPools();
}
}
finally
{
// 清理:确保所有连接池已释放
SqliteConnection.ClearAllPools();
GC.Collect();
GC.WaitForPendingFinalizers();
if (File.Exists(tempDbPath)) File.Delete(tempDbPath);
var w = tempDbPath + "-wal";
var s = tempDbPath + "-shm";
if (File.Exists(w)) File.Delete(w);
if (File.Exists(s)) File.Delete(s);
}
}
/// <summary>
/// 验证 Migrate 是幂等的:第二次调用不会报错。
/// 模拟多次启动 MauiProgram 的场景。
/// </summary>
[Fact]
public void InitializeDatabase_Migrate_Idempotent()
{
using var context = CreateContext();
context.Database.Migrate();
// 第二次 Migrate 不应抛出异常
var exception = Record.Exception(() => context.Database.Migrate());
Assert.Null(exception);
}
/// <summary>
/// 验证迁移后可以执行日志中失败的查询。
/// 对应日志第 23-27 行 SELECT 查询。
/// </summary>
[Fact]
public void InitializeDatabase_CanExecuteQueryFromLog()
{
using var context = CreateContext();
context.Database.Migrate();
// 创建测试用户
var userId = Guid.NewGuid();
context.Users.Add(new UserEntity
{
Id = userId,
UserName = "test",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
context.SaveChanges();
// 插入测试任务
var taskId = Guid.NewGuid();
context.Tasks.Add(new TaskEntity
{
Id = taskId,
UserId = userId,
Title = "测试",
Priority = TaskPriority.Medium,
Code = "T001",
CreationTime = DateTime.UtcNow,
CreatorId = userId
});
context.SaveChanges();
// 执行日志中的查询:LEFT JOIN T_Tasks ON ParentTaskId
var tasks = context.Tasks
.Include(t => t.ParentTask)
.OrderBy(t => t.Id)
.ToList();
// 验证查询不抛异常且返回结果
Assert.NotNull(tasks);
Assert.NotEmpty(tasks);
Assert.Contains(tasks, t => t.Title == "测试");
}
/// <summary>
/// 验证迁移顺序正确:MakeTaskEntityAbpCompatible 在 AddMeetingAndDescriptionFields 之前执行。
/// 这是日志中 "no such table: T_Tasks" 错误的根因验证。
/// </summary>
[Fact]
public void InitializeDatabase_MigrationOrder_Correct()
{
using var context = CreateContext();
context.Database.Migrate();
var history = context.GetService<IHistoryRepository>();
var appliedMigrations = history.GetAppliedMigrations()
.Select(m => m.MigrationId)
.ToList();
// 找到两个迁移的索引位置
var abpCompatIndex = appliedMigrations.FindIndex(m => m.Contains("MakeTaskEntityAbpCompatible"));
var meetingFieldsIndex = appliedMigrations.FindIndex(m => m.Contains("AddMeetingAndDescriptionFields"));
Assert.True(abpCompatIndex >= 0, "MakeTaskEntityAbpCompatible should be applied");
Assert.True(meetingFieldsIndex >= 0, "AddMeetingAndDescriptionFields should be applied");
// MakeTaskEntityAbpCompatible 必须在 AddMeetingAndDescriptionFields 之前
Assert.True(abpCompatIndex < meetingFieldsIndex,
$"MakeTaskEntityAbpCompatible (index {abpCompatIndex}) must be before AddMeetingAndDescriptionFields (index {meetingFieldsIndex})");
}
// ========== 辅助方法 ==========
private static List<string> GetTableNames(TodoDbContext context)
{
return context.Database.SqlQueryRaw<string>(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__EF%' ORDER BY name"
).ToList();
}
private static List<string> GetColumnNames(TodoDbContext context, string tableName)
{
var columns = new List<string>();
var connection = context.Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info('{tableName}')";
using var reader = command.ExecuteReader();
while (reader.Read())
{
columns.Add(reader.GetString(1)); // name 列是第 2 列 (index 1)
}
return columns;
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Hua.Todo.Application\Hua.Todo.Application.csproj" />
<ProjectReference Include="..\..\src\Hua.Todo.Core\Hua.Todo.Core.csproj" />
</ItemGroup>
</Project>