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,553 @@
using Hua.Todo.Application.Models;
using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Application.Services.Voice;
using Hua.Todo.Application.Services.Voice.Models;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Services;
using Hua.Todo.Core.Voice;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// 语音意图解析与命令执行器单元测试。
/// 覆盖 RuleIntentParser(离线规则解析)、VoiceCommandExecutor(命令执行)、模型序列化。
/// </summary>
public class VoiceIntentParserTests
{
#region RuleIntentParser
[Fact]
public void Parse_Create_ReturnsCreateIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("创建任务 开会").Result;
Assert.Equal(VoiceIntent.CREATE, result.Intent);
Assert.Equal("开会", result.Params["title"]);
Assert.True(result.Confidence >= 0.8);
}
[Fact]
public void Parse_Create_WithShortVerb_ReturnsCreateIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("加 测试").Result;
Assert.Equal(VoiceIntent.CREATE, result.Intent);
Assert.Equal("测试", result.Params["title"]);
}
[Fact]
public void Parse_Delete_ReturnsDeleteIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("删除任务 开会").Result;
Assert.Equal(VoiceIntent.DELETE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_Complete_ReturnsCompleteIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("完成任务 开会").Result;
Assert.Equal(VoiceIntent.COMPLETE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_Uncomplete_ReturnsUncompleteIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("取消完成 开会").Result;
Assert.Equal(VoiceIntent.UNCOMPLETE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_Update_ReturnsUpdateIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("修改任务 开会 改为 团队会议").Result;
Assert.Equal(VoiceIntent.UPDATE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
Assert.Equal("团队会议", result.Params["newTitle"]);
}
[Fact]
public void Parse_Query_ReturnsQueryIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("查看未完成任务").Result;
Assert.Equal(VoiceIntent.QUERY, result.Intent);
Assert.Equal("未完成任务", result.Params["filter"]);
}
[Fact]
public void Parse_Query_HighPriority_ReturnsQueryIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("列出高优先级任务").Result;
Assert.Equal(VoiceIntent.QUERY, result.Intent);
Assert.Equal("高优先级任务", result.Params["filter"]);
}
[Fact]
public void Parse_AddSubtask_ReturnsAddSubtaskIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("给任务开会添加子任务 准备PPT").Result;
Assert.Equal(VoiceIntent.ADD_SUBTASK, result.Intent);
Assert.Equal("任务开会", result.Params["parentTitle"]);
Assert.Equal("准备PPT", result.Params["subTitle"]);
}
[Fact]
public void Parse_Unknown_ReturnsUnknownIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("今天天气真好").Result;
Assert.Equal(VoiceIntent.UNKNOWN, result.Intent);
Assert.Equal(0.0, result.Confidence);
}
[Fact]
public void Parse_AiBreakdown_ReturnsAiBreakdownIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("帮我拆分 开会").Result;
Assert.Equal(VoiceIntent.AI_BREAKDOWN, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_EmptyText_ReturnsUnknown()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync(" ").Result;
Assert.Equal(VoiceIntent.UNKNOWN, result.Intent);
}
#endregion
#region VoiceCommandExecutor Mock ITaskService
[Fact]
public async Task Execute_Create_Success()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.CREATE,
Params = new Dictionary<string, string> { ["title"] = "新任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已创建任务", response.Result.Message);
Assert.Single(mockService.CreatedTasks);
Assert.Equal("新任务", mockService.CreatedTasks[0].Title);
}
[Fact]
public async Task Execute_Create_WithPriority_Success()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.CREATE,
Params = new Dictionary<string, string> { ["title"] = "紧急任务", ["priority"] = "高" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Equal(TaskPriority.High, mockService.CreatedTasks[0].Priority);
}
[Fact]
public async Task Execute_Delete_Success()
{
var mockService = new MockTaskServiceForVoice();
var taskId = mockService.AddExistingTask("测试任务", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.DELETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "测试任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已删除任务", response.Result.Message);
Assert.Single(mockService.DeletedIds);
Assert.Equal(taskId, mockService.DeletedIds[0]);
}
[Fact]
public async Task Execute_Complete_Success()
{
var mockService = new MockTaskServiceForVoice();
mockService.AddExistingTask("待完成", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.COMPLETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "待完成" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已完成任务", response.Result.Message);
}
[Fact]
public async Task Execute_Ambiguity_ReturnsCandidates()
{
var mockService = new MockTaskServiceForVoice();
mockService.AddExistingTask("开会");
mockService.AddExistingTask("开会讨论方案");
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.DELETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "开会" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.False(response.Result.Success);
Assert.True(response.Result.Ambiguity);
Assert.Equal(2, response.Result.Candidates.Count);
}
[Fact]
public async Task Execute_NotFound_ReturnsError()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.DELETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "不存在的任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.False(response.Result.Success);
Assert.False(response.Result.Ambiguity);
}
[Fact]
public async Task Execute_AddSubTask_Success()
{
var mockService = new MockTaskServiceForVoice();
var parentTaskId = mockService.AddExistingTask("父任务");
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.ADD_SUBTASK,
Params = new Dictionary<string, string> { ["parentTitle"] = "父任务", ["subTitle"] = "子任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("添加子任务", response.Result.Message);
Assert.Equal(parentTaskId, mockService.CreatedTasks[0].ParentTaskId);
}
[Fact]
public async Task Execute_Uncomplete_Success()
{
var mockService = new MockTaskServiceForVoice();
mockService.AddExistingTask("已完成的任务", true); // IsCompleted = true
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.UNCOMPLETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "已完成的任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已取消完成", response.Result.Message);
}
[Fact]
public async Task Execute_Confirmation_Complete_Success()
{
var mockService = new MockTaskServiceForVoice();
var confirmTaskId = mockService.AddExistingTask("确认测试", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var response = await executor.ExecuteConfirmedAsync(VoiceIntent.COMPLETE, confirmTaskId);
Assert.True(response.Result.Success);
Assert.Contains("已完成任务", response.Result.Message);
}
[Fact]
public async Task Execute_Confirmation_Delete_Success()
{
var mockService = new MockTaskServiceForVoice();
var deleteConfirmId = mockService.AddExistingTask("删除确认", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var response = await executor.ExecuteConfirmedAsync(VoiceIntent.DELETE, deleteConfirmId);
Assert.True(response.Result.Success);
Assert.Contains("已删除任务", response.Result.Message);
}
[Fact]
public async Task Execute_UnknownIntent_ReturnsError()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.UNKNOWN,
Confidence = 0.0
};
var response = await executor.ExecuteAsync(intentResult);
Assert.False(response.Result.Success);
Assert.Equal("没听懂,请再说一次", response.Result.Message);
Assert.Equal(VoiceIntent.UNKNOWN, response.Intent);
}
#endregion
#region HybridVoiceIntentParser 线
[Fact]
public async Task HybridParser_OfflineMode_UsesRuleParser()
{
// 设置离线模式环境变量
Environment.SetEnvironmentVariable("VOICE_ONLINE", "false");
try
{
var parser = new HybridVoiceIntentParser(null!, new RuleIntentParser(), NullLogger<HybridVoiceIntentParser>.Instance);
var result = await parser.ParseAsync("创建任务 测试");
Assert.Equal(VoiceIntent.CREATE, result.Intent);
Assert.Equal("测试", result.Params["title"]);
}
finally
{
Environment.SetEnvironmentVariable("VOICE_ONLINE", null);
}
}
#endregion
#region AiBreakdownService
[Fact]
public async Task AiBreakdown_NoTask_ReturnsEmptyList()
{
var mockService = new MockTaskServiceForVoice();
var mockLlm = new MockLlmClientService("");
var breakdownService = new AiBreakdownService(mockLlm, mockService, NullLogger<AiBreakdownService>.Instance);
var request = new AiBreakdownRequest { TaskId = Guid.NewGuid() };
var response = await breakdownService.GetBreakdownAsync(request);
Assert.NotNull(response);
Assert.Empty(response.Suggestions);
}
[Fact]
public async Task AiBreakdown_Confirm_CreatesSubTasks()
{
var mockService = new MockTaskServiceForVoice();
var mockLlm = new MockLlmClientService("");
var breakdownService = new AiBreakdownService(mockLlm, mockService, NullLogger<AiBreakdownService>.Instance);
var confirmParentId = Guid.NewGuid();
var confirmRequest = new AiBreakdownConfirmRequest
{
ParentTaskId = confirmParentId,
SubTasks = new List<SubTaskItem>
{
new() { Title = "子任务1", Priority = TaskPriority.High },
new() { Title = "子任务2", Priority = TaskPriority.Medium }
}
};
var created = await breakdownService.ConfirmBreakdownAsync(confirmRequest);
Assert.Equal(2, created.Count);
Assert.Equal("子任务1", created[0].Title);
Assert.Equal(TaskPriority.High, created[0].Priority);
Assert.Equal(confirmParentId, created[0].ParentTaskId);
}
#endregion
}
#region Mock
/// <summary>
/// Mock ITaskService 实现,用于 VoiceCommandExecutor 和 AiBreakdownService 测试。
/// 跟踪创建、删除操作并支持预设任务数据。
/// </summary>
public class MockTaskServiceForVoice : ITaskService
{
private readonly List<TaskDto> _tasks = new();
private Guid _nextId = Guid.NewGuid();
/// <summary>
/// 跟踪已创建的任务。
/// </summary>
public List<TaskDto> CreatedTasks { get; } = new();
/// <summary>
/// 跟踪已删除的 ID。
/// </summary>
public List<Guid> DeletedIds { get; } = new();
/// <summary>
/// 预设一个已有任务,返回创建的任务 ID 供后续断言使用。
/// </summary>
public Guid AddExistingTask(string title, bool isCompleted = false)
{
var id = Guid.NewGuid();
_tasks.Add(new TaskDto
{
Id = id,
Title = title,
Priority = TaskPriority.Medium,
IsCompleted = isCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
SubTasks = new List<TaskDto>()
});
return id;
}
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(_tasks.ToList());
public Task<TaskDto?> GetTaskByIdAsync(Guid id) => Task.FromResult(_tasks.FirstOrDefault(t => t.Id == id));
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(_tasks.Where(t => !t.IsCompleted).ToList());
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(_tasks.Where(t => t.IsCompleted).ToList());
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{
var task = new TaskDto
{
Id = Guid.NewGuid(),
Title = dto.Title,
Priority = dto.Priority,
IsCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = dto.ParentTaskId,
SubTasks = new List<TaskDto>()
};
_tasks.Add(task);
CreatedTasks.Add(task);
return Task.FromResult(task);
}
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{
var task = _tasks.FirstOrDefault(t => t.Id == dto.Id);
if (task == null) throw new KeyNotFoundException($"Task with ID {dto.Id} not found");
if (!string.IsNullOrEmpty(dto.Title)) task.Title = dto.Title;
if (dto.Priority.HasValue) task.Priority = dto.Priority.Value;
task.UpdatedAt = DateTime.UtcNow;
return Task.FromResult(task);
}
public Task<TaskDto> ToggleCompleteAsync(Guid id)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task == null) throw new KeyNotFoundException($"Task with ID {id} not found");
task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
return Task.FromResult(task);
}
public Task DeleteTaskAsync(Guid id)
{
DeletedIds.Add(id);
_tasks.RemoveAll(t => t.Id == id);
return Task.CompletedTask;
}
public Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
{
return Task.FromResult(_tasks.Where(t => t.ParentTaskId == parentTaskId).ToList());
}
}
/// <summary>
/// Mock ILlmClientService 实现,返回预设文本或抛出异常。
/// </summary>
public class MockLlmClientService : ILlmClientService
{
private readonly string _presetResponse;
/// <summary>
/// 创建 Mock LLM 客户端。
/// </summary>
/// <param name="presetResponse">预设的 LLM 响应文本。</param>
public MockLlmClientService(string presetResponse)
{
_presetResponse = presetResponse;
}
/// <summary>
/// 返回预设响应(或抛出异常以模拟 LLM 不可用)。
/// </summary>
public Task<string> SendAsync(string prompt, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_presetResponse))
throw new HttpRequestException("LLM unavailable");
return Task.FromResult(_presetResponse);
}
}
#endregion