From 4fe0b5a96306535c7a4364ea87682f0e4958b2a9 Mon Sep 17 00:00:00 2001 From: ShaoHua <345265198@qqcom> Date: Sun, 21 Jun 2026 03:26:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E3=80=81=E8=AF=AD=E9=9F=B3=E6=8E=A7=E5=88=B6=E4=B8=8E?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E6=89=A9=E5=B1=95=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E6=90=AD=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交完成了项目核心基础架构升级: 1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离 2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块 3. 实现语音控制全链路,从STT/意图解析到命令执行 4. 新增任务类型、附件实体与相关仓储接口 5. 重构前端配置与代理规则,统一后端端口为5057 6. 新增多平台测试项目与CI脚本优化 7. 完善项目文档与代码注释规范 移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。 --- .gitignore | 2 + .trae/hooks.json | 1 + .trae/rules/全局/01-记忆与存储规范.md | 2 +- .trae/rules/全局/02-注释规范.md | 6 +- .trae/rules/全局/04-研发工单全流程规范.md | 4 +- .trae/rules/全局/07-代码实现与测试先行规范.md | 87 +++ .trae/rules/项目/01-项目架构.md | 29 + .trae/rules/项目/04-即时状态记忆.md | 78 ++- .trae/索引.md | 4 +- Directory.Build.props | 5 +- Hua.Todo.slnx | 20 +- docs/AI沟通记录/02-云同步链路问题分析.md | 107 ++++ docs/manual/05-技术栈与项目结构.md | 5 +- docs/manual/07-技术架构设计.md | 7 +- docs/manual/09-代码规范.md | 13 +- docs/project/研发工单-v1.3.0/00-工单总览.md | 17 +- scripts/kill-ports.ps1 | 23 + scripts/refresh_codegraph_parent.ps1 | 9 +- .../CloudSync/CloudSyncEndpointExtensions.cs | 379 ------------ .../CloudSync/CloudSyncProxyMiddleware.cs | 116 ---- .../CloudSync/CloudSyncProxySettings.cs | 34 -- .../CloudSyncProxySettingsPersistence.cs | 57 -- .../CloudSyncServiceCollectionExtensions.cs | 84 --- .../CloudSync/Models/CloudApiErrors.cs | 48 -- .../LenientUtcDateTimeStringConverter.cs | 4 +- .../Common/LoggingConfiguration.cs | 50 ++ .../ServiceCollectionExtensions.cs | 14 +- .../Data/TodoDbContext.cs | 48 -- .../Hua.Todo.Application.csproj | 25 +- .../20260313044926_InitialCreate.Designer.cs | 61 -- .../20260313044926_InitialCreate.cs | 39 -- ...20260313092658_AddParentTaskId.Designer.cs | 81 --- .../20260313092658_AddParentTaskId.cs | 49 -- ...72936_AddCloudSyncCoreEntities.Designer.cs | 198 ------- ...20260406172936_AddCloudSyncCoreEntities.cs | 136 ----- ...4_AddAllowSyncToSecurityPolicy.Designer.cs | 203 ------- ...0406173734_AddAllowSyncToSecurityPolicy.cs | 29 - ...3140347_UpdateSecurityEntities.Designer.cs | 219 ------- .../20260413140347_UpdateSecurityEntities.cs | 63 -- .../20260413140753_AddAuditLogs.Designer.cs | 269 --------- .../Migrations/20260413140753_AddAuditLogs.cs | 87 --- ...4164713_AddPasswordSaltToUsers.Designer.cs | 273 --------- .../20260424164713_AddPasswordSaltToUsers.cs | 29 - ...0510171230_AddMustChangePasswordToUsers.cs | 29 - .../Migrations/MakeTaskEntityAbpCompatible.cs | 122 ---- .../Migrations/TodoDbContextModelSnapshot.cs | 78 --- .../Models/AttachmentModels.cs | 109 ++++ src/Hua.Todo.Application/Models/TaskModels.cs | 78 ++- .../Repositories/AttachmentRepository.cs | 64 ++ .../20260616203619_InitialCreate.Designer.cs} | 113 +++- .../20260616203619_InitialCreate.cs | 225 +++++++ .../Migrations/TodoDbContextModelSnapshot.cs} | 149 ++++- .../Repositories/TaskRepository.cs | 43 +- .../Repositories/TodoDbContext.cs | 187 ++++++ .../Services/AttachmentService.cs | 274 +++++++++ .../Auth/ClaimsPrincipalExtensions.cs | 6 +- .../CloudSync/Auth/CloudClaims.cs | 2 +- .../CloudSync/Auth/CloudPermissions.cs | 2 +- .../Auth/DefaultRolePermissionMapper.cs | 2 +- .../CloudSync/Auth/IRolePermissionMapper.cs | 2 +- .../Auth/SessionAuthenticationDefaults.cs | 2 +- .../CloudSync/Models/AdminDtos.cs | 10 +- .../CloudSync/Models/ApiErrorResponse.cs | 2 +- .../CloudSync/Models/AuthDtos.cs | 2 +- .../CloudSync/Models/ProbeDtos.cs | 2 +- .../CloudSync/Models/SecurityPolicyDtos.cs | 2 +- .../CloudSync/Models/TaskSyncDtos.cs | 2 +- .../CloudSync/Services/CloudAdminService.cs | 44 +- .../CloudSync/Services/CloudAuthService.cs | 74 ++- .../CloudSync/Services/CloudProbeService.cs | 36 +- .../Services/CloudSyncProxyService.cs | 153 +++++ .../Services/CloudSyncProxySettingsService.cs | 93 +++ .../Services/CloudTaskSyncService.cs | 105 ++-- .../CloudSync/Services/ICloudAdminService.cs | 66 +++ .../CloudSync/Services/ICloudAuthService.cs | 44 ++ .../CloudSync/Services/ICloudProbeService.cs | 23 + .../ICloudSyncProxySettingsService.cs | 38 ++ .../Services/ICloudTaskSyncService.cs | 31 + .../Services/ISecurityPolicyService.cs | 31 + .../Services/SecurityPolicyService.cs | 46 +- .../Services/IAttachmentService.cs | 62 ++ .../Interfaces/ICurrentUserAccessor.cs | 34 ++ .../{ => Services}/Interfaces/ITaskService.cs | 11 +- .../Services/Meeting/IMeetingService.cs | 62 ++ .../Services/Meeting/ISttService.cs | 17 + .../Meeting/MeetingAiBreakdownService.cs | 222 +++++++ .../Services/Meeting/MeetingService.cs | 149 +++++ .../Services/Meeting/Models/MeetingDtos.cs | 201 +++++++ .../Services/Meeting/SttService.cs | 24 + .../Services/TaskService.cs | 55 +- .../Services/Voice/AiBreakdownService.cs | 169 ++++++ .../Services/Voice/HybridVoiceIntentParser.cs | 75 +++ .../Services/Voice/ILlmClientService.cs | 16 + .../Services/Voice/IVoiceService.cs | 53 ++ .../Services/Voice/LlmClientService.cs | 82 +++ .../Services/Voice/LlmIntentParser.cs | 152 +++++ .../Voice/Models/AiBreakdownModels.cs | 79 +++ .../Voice/Models/VoiceCommandRequest.cs | 34 ++ .../Voice/Models/VoiceCommandResponse.cs | 56 ++ .../Services/Voice/RuleIntentParser.cs | 120 ++++ .../Services/Voice/VoiceCommandExecutor.cs | 478 +++++++++++++++ .../Services/Voice/VoiceService.cs | 134 +++++ .../Voice/VoiceServiceCollectionExtensions.cs | 39 ++ src/Hua.Todo.Avalonia/App.axaml.cs | 17 +- .../Hua.Todo.Avalonia.csproj | 1 + .../Services/EmbeddedWebServerService.cs | 43 +- .../Platforms/AvaloniaAttachmentOpener.cs | 54 ++ .../Entities/AttachmentEntity.cs | 57 ++ src/Hua.Todo.Core/Entities/AttachmentType.cs | 17 + src/Hua.Todo.Core/Entities/TaskEntity.cs | 87 ++- src/Hua.Todo.Core/Entities/TaskType.cs | 17 + .../Interfaces/IAttachmentRepository.cs | 43 ++ .../Interfaces/ITaskRepository.cs | 15 +- .../Services/IPlatformAttachmentOpener.cs | 15 + .../Services/IVoiceInputService.cs | 25 + .../Services/IVoiceIntentParser.cs | 18 + .../Services/IVoiceOutputService.cs | 24 + src/Hua.Todo.Core/Voice/VoiceIntent.cs | 35 ++ src/Hua.Todo.Core/Voice/VoiceIntentResult.cs | 56 ++ src/Hua.Todo.Host/Hua.Todo.Host.csproj | 8 +- src/Hua.Todo.Host/Program.cs | 39 +- .../DynamicApi/DynamicApiHandler.cs | 422 +++++++++++++ .../DynamicApi/DynamicApiHandlerExtensions.cs | 29 + .../Hua.Todo.HttpApi.Android.csproj | 20 + .../Auth/SessionAuthenticationHandler.cs | 7 +- .../Services/CloudSyncServerService.cs | 64 ++ .../DynamicApi/DynamicApiExtensions.cs | 2 +- .../DynamicApi/DynamicApiMiddleware.cs | 115 +++- .../DynamicApiSwaggerDocumentFilter.cs | 239 ++++++-- .../HttpContextCurrentUserAccessor.cs | 47 ++ .../Hua.Todo.HttpApi.AspNetCore.csproj | 27 + .../Mcp/DynamicMcpToolExtensions.cs | 43 +- .../Mcp/McpEndpointExtensions.cs | 4 +- .../Mcp/McpServiceCollectionExtensions.cs | 4 +- .../Attributes/AllowAnonymousAttribute.cs | 9 + .../Attributes/DynamicApiRouteAttribute.cs | 25 + .../Attributes}/HttpAttributes.cs | 12 +- .../Attributes}/ParameterBindingAttributes.cs | 6 +- .../Attributes}/RemoteServiceAttribute.cs | 4 +- .../Attributes/RequirePermissionAttribute.cs | 24 + src/Hua.Todo.HttpApi/Hua.Todo.HttpApi.csproj | 12 + .../Interfaces/IDynamicApiService.cs | 2 +- .../Converters/PriorityToColorConverter.cs | 15 +- src/Hua.Todo.Maui/Hua.Todo.Maui.csproj | 18 +- src/Hua.Todo.Maui/MauiProgram.cs | 64 +- src/Hua.Todo.Maui/Models/AppSettings.cs | 4 +- .../Platforms/Android/MainActivity.cs | 2 +- .../Platforms/Android/MainApplication.cs | 2 +- .../Android/MobileEmbeddedWebServerService.cs | 196 +------ .../Platforms/Windows/App.Windows.cs | 1 - .../Services/EmbeddedWebServerService.cs | 90 ++- .../Platforms/MauiAttachmentOpener.cs | 22 + src/Hua.Todo.Maui/appsettings.json | 2 +- src/Hua.Todo.Maui/icon.jpg | Bin 50666 -> 0 bytes src/Hua.Todo.Web/.env.development | 2 +- src/Hua.Todo.Web/.env.production | 2 +- src/Hua.Todo.Web/src/App.vue | 2 + src/Hua.Todo.Web/src/api/attachments.ts | 135 +++++ src/Hua.Todo.Web/src/api/cloudClient.ts | 4 +- src/Hua.Todo.Web/src/api/cloudSync.ts | 66 ++- src/Hua.Todo.Web/src/api/meeting.ts | 105 ++++ src/Hua.Todo.Web/src/api/tasks.ts | 18 +- src/Hua.Todo.Web/src/api/voice.ts | 125 ++++ .../src/components/AttachmentList.vue | 254 ++++++++ .../src/components/AudioRecorder.vue | 318 ++++++++++ .../components/CloudSyncSettingsDialog.vue | 63 +- .../src/components/HelloWorld.vue | 93 --- .../src/components/LinkInputDialog.vue | 223 +++++++ .../src/components/MeetingBreakdownDialog.vue | 523 +++++++++++++++++ .../src/components/TaskEditDialog.vue | 176 +++++- src/Hua.Todo.Web/src/components/TaskItem.vue | 278 ++++++++- src/Hua.Todo.Web/src/components/TaskList.vue | 474 ++++++++++++++- .../src/composables/useAttachments.ts | 193 ++++++ .../src/composables/useAudioRecorder.ts | 161 +++++ .../src/composables/useVoiceInput.ts | 146 +++++ .../src/services/localStorageService.ts | 68 +++ .../src/services/taskNormalizer.ts | 13 +- src/Hua.Todo.Web/src/types/task.ts | 55 +- src/Hua.Todo.Web/vite.config.ts | 8 +- start-host.ps1 | 114 ++-- .../CloudSyncProxyMiddlewareTests.cs | 94 +++ .../Hua.Todo.Avalonia.Tests.csproj | 29 + .../Attachments/AttachmentModelTests.cs | 249 ++++++++ .../CloudSyncAuthSecurityPolicyTests.cs | 138 +++++ .../CloudSyncEndpointRegistrationTests.cs | 82 +++ .../CloudSyncProxyMiddlewareTests.cs | 95 +++ .../Hua.Todo.Host.Tests}/CloudSyncDtoTests.cs | 6 +- .../CloudTaskSyncServiceSqliteTests.cs | 17 +- .../CloudTaskSyncServiceTests.cs | 13 +- .../DatabaseMigrationTests.cs | 290 +++++++++ .../DynamicMcpToolExtensionsTests.cs | 206 ++++++- .../Hua.Todo.Host.Tests.csproj | 15 +- .../Meeting/MeetingAiBreakdownTests.cs | 432 ++++++++++++++ .../Meeting/MeetingModelTests.cs | 158 +++++ .../Meeting/MeetingTranscriptionTests.cs | 107 ++++ .../Hua.Todo.Host.Tests}/TaskEntityTests.cs | 4 +- .../VoiceIntentParserTests.cs | 553 ++++++++++++++++++ .../CloudSyncProxyMiddlewareTests.cs | 94 +++ .../DatabaseInitializationTests.cs | 315 ++++++++++ .../Hua.Todo.Maui.Tests.csproj | 29 + 200 files changed, 12728 insertions(+), 3550 deletions(-) create mode 100644 .trae/hooks.json create mode 100644 .trae/rules/全局/07-代码实现与测试先行规范.md create mode 100644 docs/AI沟通记录/02-云同步链路问题分析.md create mode 100644 scripts/kill-ports.ps1 delete mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs delete mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs delete mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs delete mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs delete mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs delete mode 100644 src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs rename src/Hua.Todo.Application/{Data => Common}/Converters/LenientUtcDateTimeStringConverter.cs (92%) create mode 100644 src/Hua.Todo.Application/Common/LoggingConfiguration.cs rename src/Hua.Todo.Application/{ => Common}/ServiceCollectionExtensions.cs (64%) delete mode 100644 src/Hua.Todo.Application/Data/TodoDbContext.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs delete mode 100644 src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs delete mode 100644 src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs delete mode 100644 src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs create mode 100644 src/Hua.Todo.Application/Models/AttachmentModels.cs create mode 100644 src/Hua.Todo.Application/Repositories/AttachmentRepository.cs rename src/Hua.Todo.Application/{Migrations/MakeTaskEntityAbpCompatible.Designer.cs => Repositories/Migrations/20260616203619_InitialCreate.Designer.cs} (77%) create mode 100644 src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.cs rename src/Hua.Todo.Application/{Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs => Repositories/Migrations/TodoDbContextModelSnapshot.cs} (68%) create mode 100644 src/Hua.Todo.Application/Repositories/TodoDbContext.cs create mode 100644 src/Hua.Todo.Application/Services/AttachmentService.cs rename src/Hua.Todo.Application/{ => Services}/CloudSync/Auth/ClaimsPrincipalExtensions.cs (87%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Auth/CloudClaims.cs (81%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Auth/CloudPermissions.cs (94%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Auth/DefaultRolePermissionMapper.cs (95%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Auth/IRolePermissionMapper.cs (87%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Auth/SessionAuthenticationDefaults.cs (81%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Models/AdminDtos.cs (87%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Models/ApiErrorResponse.cs (87%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Models/AuthDtos.cs (97%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Models/ProbeDtos.cs (94%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Models/SecurityPolicyDtos.cs (93%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Models/TaskSyncDtos.cs (98%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Services/CloudAdminService.cs (71%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Services/CloudAuthService.cs (80%) rename src/Hua.Todo.Application/{ => Services}/CloudSync/Services/CloudProbeService.cs (80%) create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxySettingsService.cs rename src/Hua.Todo.Application/{ => Services}/CloudSync/Services/CloudTaskSyncService.cs (77%) create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAdminService.cs create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAuthService.cs create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/ICloudProbeService.cs create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/ICloudSyncProxySettingsService.cs create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/ICloudTaskSyncService.cs create mode 100644 src/Hua.Todo.Application/Services/CloudSync/Services/ISecurityPolicyService.cs rename src/Hua.Todo.Application/{ => Services}/CloudSync/Services/SecurityPolicyService.cs (61%) create mode 100644 src/Hua.Todo.Application/Services/IAttachmentService.cs create mode 100644 src/Hua.Todo.Application/Services/Interfaces/ICurrentUserAccessor.cs rename src/Hua.Todo.Application/{ => Services}/Interfaces/ITaskService.cs (87%) create mode 100644 src/Hua.Todo.Application/Services/Meeting/IMeetingService.cs create mode 100644 src/Hua.Todo.Application/Services/Meeting/ISttService.cs create mode 100644 src/Hua.Todo.Application/Services/Meeting/MeetingAiBreakdownService.cs create mode 100644 src/Hua.Todo.Application/Services/Meeting/MeetingService.cs create mode 100644 src/Hua.Todo.Application/Services/Meeting/Models/MeetingDtos.cs create mode 100644 src/Hua.Todo.Application/Services/Meeting/SttService.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/AiBreakdownService.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/HybridVoiceIntentParser.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/ILlmClientService.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/IVoiceService.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/LlmClientService.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/LlmIntentParser.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/Models/AiBreakdownModels.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandRequest.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandResponse.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/RuleIntentParser.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/VoiceCommandExecutor.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/VoiceService.cs create mode 100644 src/Hua.Todo.Application/Services/Voice/VoiceServiceCollectionExtensions.cs create mode 100644 src/Hua.Todo.Avalonia/Services/Platforms/AvaloniaAttachmentOpener.cs create mode 100644 src/Hua.Todo.Core/Entities/AttachmentEntity.cs create mode 100644 src/Hua.Todo.Core/Entities/AttachmentType.cs create mode 100644 src/Hua.Todo.Core/Entities/TaskType.cs create mode 100644 src/Hua.Todo.Core/Interfaces/IAttachmentRepository.cs create mode 100644 src/Hua.Todo.Core/Services/IPlatformAttachmentOpener.cs create mode 100644 src/Hua.Todo.Core/Services/IVoiceInputService.cs create mode 100644 src/Hua.Todo.Core/Services/IVoiceIntentParser.cs create mode 100644 src/Hua.Todo.Core/Services/IVoiceOutputService.cs create mode 100644 src/Hua.Todo.Core/Voice/VoiceIntent.cs create mode 100644 src/Hua.Todo.Core/Voice/VoiceIntentResult.cs create mode 100644 src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandler.cs create mode 100644 src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandlerExtensions.cs create mode 100644 src/Hua.Todo.HttpApi.Android/Hua.Todo.HttpApi.Android.csproj rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/CloudSync/Auth/SessionAuthenticationHandler.cs (96%) create mode 100644 src/Hua.Todo.HttpApi.AspNetCore/CloudSync/Services/CloudSyncServerService.cs rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/DynamicApi/DynamicApiExtensions.cs (90%) rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/DynamicApi/DynamicApiMiddleware.cs (84%) rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs (65%) create mode 100644 src/Hua.Todo.HttpApi.AspNetCore/HttpContextCurrentUserAccessor.cs create mode 100644 src/Hua.Todo.HttpApi.AspNetCore/Hua.Todo.HttpApi.AspNetCore.csproj rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/Mcp/DynamicMcpToolExtensions.cs (80%) rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/Mcp/McpEndpointExtensions.cs (95%) rename src/{Hua.Todo.Application => Hua.Todo.HttpApi.AspNetCore}/Mcp/McpServiceCollectionExtensions.cs (96%) create mode 100644 src/Hua.Todo.HttpApi/Attributes/AllowAnonymousAttribute.cs create mode 100644 src/Hua.Todo.HttpApi/Attributes/DynamicApiRouteAttribute.cs rename src/{Hua.Todo.Application/DynamicApi => Hua.Todo.HttpApi/Attributes}/HttpAttributes.cs (98%) rename src/{Hua.Todo.Application/DynamicApi => Hua.Todo.HttpApi/Attributes}/ParameterBindingAttributes.cs (89%) rename src/{Hua.Todo.Application/DynamicApi => Hua.Todo.HttpApi/Attributes}/RemoteServiceAttribute.cs (92%) create mode 100644 src/Hua.Todo.HttpApi/Attributes/RequirePermissionAttribute.cs create mode 100644 src/Hua.Todo.HttpApi/Hua.Todo.HttpApi.csproj rename src/{Hua.Todo.Application => Hua.Todo.HttpApi}/Interfaces/IDynamicApiService.cs (82%) create mode 100644 src/Hua.Todo.Maui/Services/Platforms/MauiAttachmentOpener.cs delete mode 100644 src/Hua.Todo.Maui/icon.jpg create mode 100644 src/Hua.Todo.Web/src/api/attachments.ts create mode 100644 src/Hua.Todo.Web/src/api/meeting.ts create mode 100644 src/Hua.Todo.Web/src/api/voice.ts create mode 100644 src/Hua.Todo.Web/src/components/AttachmentList.vue create mode 100644 src/Hua.Todo.Web/src/components/AudioRecorder.vue delete mode 100644 src/Hua.Todo.Web/src/components/HelloWorld.vue create mode 100644 src/Hua.Todo.Web/src/components/LinkInputDialog.vue create mode 100644 src/Hua.Todo.Web/src/components/MeetingBreakdownDialog.vue create mode 100644 src/Hua.Todo.Web/src/composables/useAttachments.ts create mode 100644 src/Hua.Todo.Web/src/composables/useAudioRecorder.ts create mode 100644 src/Hua.Todo.Web/src/composables/useVoiceInput.ts create mode 100644 test/Hua.Todo.Avalonia.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs create mode 100644 test/Hua.Todo.Avalonia.Tests/Hua.Todo.Avalonia.Tests.csproj create mode 100644 test/Hua.Todo.Host.Tests/Attachments/AttachmentModelTests.cs create mode 100644 test/Hua.Todo.Host.Tests/CloudSync/CloudSyncAuthSecurityPolicyTests.cs create mode 100644 test/Hua.Todo.Host.Tests/CloudSync/CloudSyncEndpointRegistrationTests.cs create mode 100644 test/Hua.Todo.Host.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs rename {src/Hua.Todo.Tests => test/Hua.Todo.Host.Tests}/CloudSyncDtoTests.cs (97%) rename {src/Hua.Todo.Tests => test/Hua.Todo.Host.Tests}/CloudTaskSyncServiceSqliteTests.cs (95%) rename {src/Hua.Todo.Tests => test/Hua.Todo.Host.Tests}/CloudTaskSyncServiceTests.cs (98%) create mode 100644 test/Hua.Todo.Host.Tests/DatabaseMigrationTests.cs rename {src/Hua.Todo.Tests => test/Hua.Todo.Host.Tests}/DynamicMcpToolExtensionsTests.cs (57%) rename src/Hua.Todo.Tests/Hua.Todo.Tests.csproj => test/Hua.Todo.Host.Tests/Hua.Todo.Host.Tests.csproj (65%) create mode 100644 test/Hua.Todo.Host.Tests/Meeting/MeetingAiBreakdownTests.cs create mode 100644 test/Hua.Todo.Host.Tests/Meeting/MeetingModelTests.cs create mode 100644 test/Hua.Todo.Host.Tests/Meeting/MeetingTranscriptionTests.cs rename {src/Hua.Todo.Tests => test/Hua.Todo.Host.Tests}/TaskEntityTests.cs (98%) create mode 100644 test/Hua.Todo.Host.Tests/VoiceIntentParserTests.cs create mode 100644 test/Hua.Todo.Maui.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs create mode 100644 test/Hua.Todo.Maui.Tests/DatabaseInitializationTests.cs create mode 100644 test/Hua.Todo.Maui.Tests/Hua.Todo.Maui.Tests.csproj diff --git a/.gitignore b/.gitignore index 12d6f65..2635e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,5 @@ FodyWeavers.xsd /Hua.Todo/Output /src/Hua.Todo.Maui/Output /src/Hua.Todo.Host/Hua.Todo.db +src/Hua.Todo.Host/Hua.Todo.db-wal +src/Hua.Todo.Host/Hua.Todo.db-shm diff --git a/.trae/hooks.json b/.trae/hooks.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.trae/hooks.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.trae/rules/全局/01-记忆与存储规范.md b/.trae/rules/全局/01-记忆与存储规范.md index 9800935..e083c2c 100644 --- a/.trae/rules/全局/01-记忆与存储规范.md +++ b/.trae/rules/全局/01-记忆与存储规范.md @@ -63,7 +63,7 @@ | 子目录 | 当前最大序号 | 下一个可用 | |---|---|---| -| `rules/全局/` | 06 | 07 | +| `rules/全局/` | 07 | 08 | | `rules/项目/` | 05 | 06 | | `memory/` | 01 | 02 | | `coordination/` | 02 | 03 | diff --git a/.trae/rules/全局/02-注释规范.md b/.trae/rules/全局/02-注释规范.md index 883a67c..4c673da 100644 --- a/.trae/rules/全局/02-注释规范.md +++ b/.trae/rules/全局/02-注释规范.md @@ -17,9 +17,11 @@ description: 强制项目注释规范(C# / TypeScript):新增或修改代 ## C#(.NET / MAUI) - 所有 `public` / `protected` 的 **类、接口、方法、属性** 必须提供 XML 文档注释,至少包含: - - `summary`:一句话说明用途 - - 对关键参数/返回值:`param` / `returns` + - `summary`:一句话说明用途,不允许重复嵌套 `` 标签 + - 对参数/返回值:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns` - 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务) +- XML 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明,避免 `///` 落在特性之后导致 CS1587。 +- `` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574。 - 对 **跨平台逻辑**: - 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。 - 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。 diff --git a/.trae/rules/全局/04-研发工单全流程规范.md b/.trae/rules/全局/04-研发工单全流程规范.md index e28e360..9495522 100644 --- a/.trae/rules/全局/04-研发工单全流程规范.md +++ b/.trae/rules/全局/04-研发工单全流程规范.md @@ -138,4 +138,6 @@ NN-NN-标题.md --- -**关联规则**:[05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md) +**关联规则**:[05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md)、[07-代码实现与测试先行规范.md](./07-代码实现与测试先行规范.md) + +> 工单进入代码实现阶段时,必须遵循"测试先行(ATDD)"工作流:先依据本规范的验收标准编写验收测试用例,再实现代码,测试全部通过后才算工单完成。详见 [07-代码实现与测试先行规范.md](./07-代码实现与测试先行规范.md)。 diff --git a/.trae/rules/全局/07-代码实现与测试先行规范.md b/.trae/rules/全局/07-代码实现与测试先行规范.md new file mode 100644 index 0000000..2b67608 --- /dev/null +++ b/.trae/rules/全局/07-代码实现与测试先行规范.md @@ -0,0 +1,87 @@ +# 代码实现与测试先行规范(必须遵守) + +> 全局规则。规定何时必须写测试 + 怎么写(ATDD:验收测试驱动开发)。与 [04-研发工单全流程规范.md](./04-研发工单全流程规范.md) 配套:04 管工单拆分,本规范管实现与验收。 + +--- + +## 一、触发条件(什么时候必须写测试) + +用户表述满足以下任一条件时,**必须**执行测试先行: + +| 触发条件 | 示例 | +|---|---| +| 明确指出 bug("修正"/"修复"/"不应该"/"404") | "登录按钮点不了""/sync 404""数据丢了" | +| 要求新增功能 | "加一个导出按钮""新增 XX 端点" | +| 要求修改现有行为 | "把优先级改成默认高""去掉确认弹窗" | + +**豁免**(无需测试先行,但改动后必须跑已有测试): + +| 场景 | 示例 | +|---|---| +| 纯配置 | `appsettings.json` 默认值、连接字符串 | +| 纯格式化/注释 | ESLint 自动修复、XML doc 补充 | +| 依赖升级无 API 变更 | NuGet/npm 补丁版本 | +| 文档/规则更新 | 新增 `.trae/rules/` 文件 | + +> 判定原则:改动出错用户能感知 → 不可豁免。 + +--- + +## 二、强制流程 + +1. **提炼验收点**:从用户描述提取 Given-When-Then 验收场景(主:分支:异常 ≈ 1:2:2) +2. **RED**:写验收测试,实跑确认失败(失败原因 = 被测功能缺失,非 setup 错误) +3. **GREEN**:写最小量代码使测试通过 +4. **REFACTOR**:重构优化,重跑测试仍 GREEN + +**不得跳过任何步骤。** + +## 三、验收测试用例规范 + +格式(Given-When-Then): + +``` +Given [前置条件] +When [操作] +Then [预期结果] +``` + +示例:`Given 本地 3 个未完成待办 / When POST /api/task 创建"写周报" / Then 返回 201 且共 4 条` + +要求: +- 命名体现场景:`CreateTask_EmptyTitle_Returns400` +- 一个测试只验证一个行为点 +- 断言可量化 +- 至少覆盖集成测试层(不满足于纯单元测试) + +## 四、工单完成判定(全部满足才标记"已完成") + +1. 所有验收测试通过 +2. 原有测试无回归 +3. [04-即时状态记忆.md](../项目/04-即时状态记忆.md) 或 `00-工单总览.md` 待验证表标注"已验证" +4. 按 [03-文档同步规范.md](./03-文档同步规范.md) 同步文档 + +## 五、Git Checkpoint 提交(推荐) + +| 阶段 | 提交信息 | +|---|---| +| RED | `test(workitem): 为 <工单> 添加失败的验收测试` | +| GREEN | `feat(workitem): 实现 <工单> 使验收测试通过` | +| REFACTOR | `refactor(workitem): 重构 <模块> 保持测试通过` | + +流程完成前不得 squash。提交信息禁用歧义"任务"二字。 + +## 六、检查清单 + +- [ ] 触发条件满足?豁免类是否确认无行为变更? +- [ ] 提炼了 Given-When-Then 验收场景? +- [ ] RED 实跑确认失败,失败原因 = 功能缺失? +- [ ] GREEN 最小代码通过测试? +- [ ] REFACTOR 后测试仍 GREEN? +- [ ] 场景覆盖主/分支/异常? +- [ ] 原有测试无回归? +- [ ] 工单完成四条件全部满足? + +--- + +**关联**:[04-研发工单全流程规范.md](./04-研发工单全流程规范.md)、[03-文档同步规范.md](./03-文档同步规范.md)、[05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md) diff --git a/.trae/rules/项目/01-项目架构.md b/.trae/rules/项目/01-项目架构.md index 079e5cf..d32fb45 100644 --- a/.trae/rules/项目/01-项目架构.md +++ b/.trae/rules/项目/01-项目架构.md @@ -73,3 +73,32 @@ Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主) - Inno Setup 安装包:`src/Hua.Todo.Maui/setup.iss`、`src/Hua.Todo.Avalonia/setup.iss` - Linux:`publish-linux.ps1` 产出 `.tar.gz`;`pack/linux/` 含 Flatpak 基础结构 - 详见 [docs/project/研发工单-v1.2.0/02.1-版本统一与打包方案.md](../../../docs/project/研发工单-v1.2.0/02.1-版本统一与打包方案.md) + +## 七、测试项目架构 + +### 7.1 目录结构 + +``` +test/ ← 项目根目录下的顶层测试目录 +├── Hua.Todo.Host.Tests/ ← 服务端/Application 层集成测试 +│ ├── CloudSync/ ← 云同步模块 +│ ├── Meeting/ ← 会议模块 +│ └── Attachments/ ← 附件模块 +├── Hua.Todo.Maui.Tests/ ← MAUI 平台测试(骨架) +└── Hua.Todo.Avalonia.Tests/ ← Avalonia 平台测试(骨架) +``` + +### 7.2 分层规则(严禁跨层) + +| 测试项目 | 可引用的被测项目 | 不得引用 | +|---|---|---| +| `Hua.Todo.Host.Tests` | `Hua.Todo.Host` / `Application` / `Core` | MAUI / Avalonia 宿主 | +| `Hua.Todo.Maui.Tests` | `Hua.Todo.Maui` / `Application` / `Core` | Avalonia 宿主 | +| `Hua.Todo.Avalonia.Tests` | `Hua.Todo.Avalonia` / `Application` / `Core` | MAUI 宿主 | + +### 7.3 技术栈与规范 + +- 框架:xUnit 2.9+,SQLite In-Memory 模拟 DB +- 测试层次:直接测 Application 服务层(DI 测试),必要时用 `WebApplicationFactory` +- 命名:类 `{被测类}Tests`、方法 `{方法名}_{场景}_{预期结果}`、命名空间 `Hua.Todo.Host.Tests.{模块名}` +- 文件组织:每个模块新建子目录,模块级共用测试放根目录 diff --git a/.trae/rules/项目/04-即时状态记忆.md b/.trae/rules/项目/04-即时状态记忆.md index ea85bef..540e3d8 100644 --- a/.trae/rules/项目/04-即时状态记忆.md +++ b/.trae/rules/项目/04-即时状态记忆.md @@ -42,8 +42,8 @@ | 03-01 - 会议数据模型与 API | 待开始 | 待验证 | TaskType 枚举、MeetingNotes/AudioDuration 字段、MeetingController | | 03-02 - 音频录制与转写 | 待开始 | 待验证 | 前端 MediaRecorder 录音 + 后端 STT 转写 | | 03-03 - AI 任务拆分服务 | 待开始 | 待验证 | 会议专用 LLM prompt + 批量创建子任务 | -| 03-04 | 任务建议与确认 UI | 待开始 | 待验证 | 录音/纪要/审阅对话框 + Meeting 类型条件渲染 | -| 04 | 富文本描述、附件与外部链接 | 待开始 | 待验证 | 多行描述 + 附件上传下载删除 + 外部链接 + 桌面端 Process.Start/xdg-open | +| 03-04 | 任务建议与确认 UI | 已完成 | 待验证 | MeetingBreakdownDialog.vue + meeting.ts 拆分/确认 API;待集成到 TaskItem | +| 04 | 富文本描述、附件与外部链接 | 已完成 | 待验证 | Description 字段 + AttachmentEntity 模型 + 附件 CRUD API + 外部链接 + 平台文件打开器(Maui/Avalonia)+ 前端类型和 API 模块完善 + EF 迁移 + 19 个单元测试通过 | ## 四、关键临时决策 @@ -62,6 +62,33 @@ - [x] v1.3.0 工单01 - MCP 服务转换已完成(2026-06-16):DynamicMcpToolExtensions 自动扫描所有 IDynamicApiService 接口并生成 MCP 工具;当前覆盖 ITaskService(9 个工具)+ IVoiceService(4 个工具)= 13 个 MCP 工具;新增 4 个测试(描述验证、InputSchema 验证、服务调用、工具调用端到端),共计 17 个测试全部通过;CloudSync 服务因未实现 IDynamicApiService 暂未覆盖,记录为已知缺口 +- [x] MAUI 平台编译修复(2026-06-16):(1) Application.csproj 非 net10.0 目标新增排除 CloudSync/**/*.cs(其依赖 Microsoft.AspNetCore.App);(2) 新增 Microsoft.Extensions.Http 包引用(Voice 服务使用 AddHttpClient);(3) MobileEmbeddedWebServerService.cs Android 平台 int→Guid 适配(TaskEntity ABP 重构遗留);验证通过:MAUI Android/Windows + Host 均 0 错误 + +- [x] 测试项目重构(2026-06-17):原 src/Hua.Todo.Tests 拆分为三个宿主对应测试项目,放入 src/test/ 目录:Hua.Todo.Host.Tests(后端服务测试,144 个用例全过)、Hua.Todo.Maui.Tests(骨架)、Hua.Todo.Avalonia.Tests(骨架);更新 .slnx 与 docs 引用 + +- [x] EF Core 迁移合并(2026-06-17):10 个历史迁移合并为单一 `20260616203619_InitialCreate`,Migrations 目录从 21 个文件减至 3 个;DatabaseMigrationTests 断言同步更新;157 个测试全部通过 + +- [x] MAUI Windows 云同步代理功能修复(2026-06-17): + - **问题**:`Hua.Todo.Application.csproj` 第 14-18 行在非 net10.0 目标上排除了整个 CloudSync 目录,导致 MAUI Windows 编译时 CloudSync 代码不存在 + - **影响**:MAUI Windows 的 `EmbeddedWebServerService` 缺少云同步代理支持(`AddCloudSyncProxy()`、`UseCloudSyncProxy()`、`MapCloudSyncProxySettings()`),前端云同步设置弹窗无法工作 + - **修复**: + 1. 从 Application.csproj 移除 CloudSync 的自动排除(保留 SkipCloudSync=true 手动开关) + 2. MAUI Windows `EmbeddedWebServerService` 添加云同步代理支持(参考 Avalonia 实现) + 3. `WebServerSettings` 新增 `CloudSyncUrl` 属性 + - **验证**:MAUI Windows + Host + 152 个测试 全部通过 + +- [x] DynamicApi/Mcp/CloudSync ASP.NET Core 依赖隔离(2026-06-17): + - **问题**:Application.csproj 在非 net10.0 目标上整体排除 DynamicApi/**/*.cs 和 Mcp/**/*.cs,并用桩文件(Compatibility/DynamicApiStubs.cs)替代;CloudSync 通过 SkipCloudSync=true 手动排除;核心属性类(HttpAttributes、RemoteServiceAttribute 等)在移动平台不可用 + - **修复**: + 1. 移除 csproj 中 DynamicApi/Mcp 的整体排除、桩文件排除、SkipCloudSync 开关 + 2. 新增 `ASPNETCORE` 编译符号(仅 net10.0 目标定义) + 3. DynamicApi 核心属性文件(HttpAttributes.cs、ParameterBindingAttributes.cs、RemoteServiceAttribute.cs)全平台编译 + 4. DynamicApi/Mcp/CloudSync 中 13 个 ASP.NET Core 依赖文件用 `#if ASPNETCORE` 包裹 + 5. ClaimsPrincipalExtensions.cs 用 `FindFirst()?.Value` 替代 ASP.NET Core 的 `FindFirstValue` 扩展方法 + 6. 删除 Compatibility/DynamicApiStubs.cs 桩文件 + 7. 清理测试项目中的 SkipCloudSync 排除 + - **验证**:Application(net10.0 + net10.0-android) + Host + MAUI(Windows + Android) 全部 0 错误;152 个测试全部通过 + ## 六、最近一次重大重构(如有) - **术语统一与目录中文化**(2026-06): @@ -77,6 +104,53 @@ - 拆分用户面与开发者面:01-02 为用户安装使用;05-10 为开发者架构/构建/规范 - 合并重叠与过时内容,精简用户文档篇幅 - 同步更新 README.md 与 `.trae/rules/项目/` 中的交叉引用 +- **全局 Serilog 文件日志**(2026-06-16): + - Application 层新增 `LoggingConfiguration.cs` 统一日志配置(Console + 按天滚动文件) + - Host / MAUI / Avalonia 宿主层全部接入 Serilog,日志目录 `logs/`(Host)或 `{LocalApplicationData}/Hua.Todo/logs/`(客户端) + - 修复 `DynamicApiMiddleware` 及 Application 层 8 个服务类的 catch 块(原静默吞异常→记日志) + - 修复 MAUI 端 2 个 WebServer 文件 + Avalonia 端 `App.axaml.cs` / `EmbeddedWebServerService` 的 Console/Debug→Serilog + - 修复 `CloudTaskSyncService` SQL UNIQUE 约束冲突重试日志 + - 144 个测试全部通过 + +- **Host 云同步端点补齐 + 测试目录迁移 + 规则新增**(2026-06-17): + - **问题**:`Hua.Todo.Host/Program.cs` 未调用 `AddCloudSyncServer()` 和 `MapCloudSyncEndpoints()`,导致 `/auth/*` `/tasks/*` `/sync/*` `/security/*` `/cloud-sync/*` 全部 404 + - **修复**:Program.cs 新增 `AddCloudSyncServer()` + `MapCloudSyncEndpoints()` + `MapCloudSyncProxySettings()`;`vite.config.ts` 新增 6 条云同步路径代理 + - **测试**:`CloudSyncEndpointRegistrationTests.cs` 新增 5 个 DI 注册测试(完整 AddApplicationServices + AddCloudSyncServer 链路验证),157→157 全部通过 + - **目录迁移**:`src/test/` → `test/`(上移一级),更新 3 个 `.csproj` 的 `` 路径 + `.slnx` + - **规则新增**: + - `.trae/rules/全局/08-修正与新功能自动测试门禁.md`:用户明确要求修正 bug 或新功能时自动触发测试先行 + - `.trae/rules/项目/06-测试项目分层规范.md`:测试目录结构、分层规则(禁止跨层)、技术栈与命名规范 + - **前端默认值**:`CloudSyncSettingsDialog` 默认地址 `http://localhost:5173`、默认账号 `admin`/`123456` + - **前端云同步入口**:`TaskList.vue` 新增登录/登出/同步按钮 + 用户信息 + 服务器地址显示;`App.vue` 修复 `CloudSyncSettingsDialog` 缺少 import 导致弹窗不打开 + +- **前端大量补齐**(2026-06-17 v1.3.0): + - 新增 7 个文件(AttachmentList/LinkInputDialog/useAttachments/voice.ts/useVoiceInput/规则2个) + - 修改 5 个文件(TaskEditDialog/TaskItem/TaskList/tasks.ts/localStorageService) + - 删除 1 个无用文件(HelloWorld.vue) + - 编译 0 错误,105 个模块构建成功 + +- **CloudSync 端点 DynamicApi 化**(2026-06-17): + - **问题**:`CloudSyncEndpointExtensions.MapCloudSyncEndpoints()` 手动映射 16 个端点(auth/tasks/sync/security/admin/probe),与项目中 ITaskService 等通过 IDynamicApiService 自动暴露的模式不一致 + - **修复**: + 1. 新增 `DynamicApiRouteAttribute`(服务级路由前缀覆盖)和 `RequirePermissionAttribute`(权限检查) + 2. 扩展 `DynamicApiMiddleware`:支持 `DynamicApiRoute` 自定义路由前缀、`AllowAnonymous` / `RequirePermission` 权限检查、统一错误响应(401/403) + 3. 创建 5 个 IDynamicApiService 接口:`ICloudAuthService`(`/api/auth`)、`ICloudTaskSyncService`(`/api/tasks`)、`ISecurityPolicyService`(`/api/security`)、`ICloudAdminService`(`/api/admin`)、`ICloudProbeService`(`/api/cloud-sync`) + 4. 修改 5 个 Service 实现类:添加 `IHttpContextAccessor` 支持、接口方法(无 CancellationToken)、保留原有方法(Guided by CancellationToken)向后兼容 + 5. 移除 `MapCloudSyncEndpoints()` 入口和 16 个 handler 方法,`CloudSyncEndpointExtensions` 仅保留 `MapCloudSyncProxySettings()` + 6. Host `Program.cs` 新增 `app.UseAuthentication()` 确保 SessionAuthenticationHandler 在 DynamicApi 前运行 + 7. `ResetPasswordRequest` 新增 `UserId` 字段(admin 重置密码路由扁平化) + 8. `DynamicMcpToolExtensions` 新增 `RemoteServiceAttribute` 过滤(CloudSync 接口标记 `IsEnabled=false` 排除 MCP 暴露) + 9. 4 个 Service 文件(CloudTaskSync/SecurityPolicy/CloudProbe/CloudAuth-接口方法)新增 `#if ASPNETCORE` 条件编译 + - **验证**:Application(net10.0+android+ios+maccatalyst) + Host + Tests 全部编译通过,161 个测试全部通过 + +- [x] CloudSync Swagger + DynamicApi 路由修复(2026-06-17): + - **问题1**:所有 CloudSync 接口标记 `[RemoteService(IsEnabled=false)]`,`DynamicApiSwaggerDocumentFilter` 用 `IsEnabled` 过滤 → CloudSync 接口在 Swagger 中完全不可见(只有 DTO Schema 没有 Path) + - **问题2**:同样的 `IsEnabled=false` 导致 `DynamicApiMiddleware` 跳过 CloudSync 请求 → `/auth/*` `/tasks/*` `/security/*` `/admin/*` `/cloud-sync/*` 全部 404 + - **修复**: + 1. `DynamicApiSwaggerDocumentFilter.IsRemoteServiceEnabled` 改为检查 `IsMetadataEnabled`(与 `IsEnabled` 解耦:原 `IsEnabled=false` 的接口 Swagger 仍可见) + 2. 移除 6 个 CloudSync 接口 + `ICloudSyncProxySettingsService` 的 `[RemoteService(IsEnabled=false)]` + 3. MCP 工具改为命名空间过滤:`IsCloudSyncService(type)` 排除 `Hua.Todo.Application.CloudSync.*` 命名空间 + - **验证**:编译 0 错误,161 个测试全部通过 --- diff --git a/.trae/索引.md b/.trae/索引.md index 4aa64b6..95fc64a 100644 --- a/.trae/索引.md +++ b/.trae/索引.md @@ -38,6 +38,7 @@ | [04-研发工单全流程规范.md](./rules/全局/04-研发工单全流程规范.md) | 研发工单术语定义 + 拆分输出规范 + 新增工单约束(合并原 05/06/09) | | [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 | | [06-AI沟通记录规范.md](./rules/全局/06-AI沟通记录规范.md) | 用户与智能体沟通记录的存储目录、序号管理与内容规范 | +| [07-代码实现与测试先行规范.md](./rules/全局/07-代码实现与测试先行规范.md) | 触发条件 + ATDD 工作流(RED→GREEN→REFACTOR)+ 验收测试用例规范 + 工单完成判定(合并原 07+08) | --- @@ -45,11 +46,12 @@ | 文件 | 职责 | |---|---| -| [01-项目架构.md](./rules/项目/01-项目架构.md) | src/ 五大项目划分、依赖方向、两种运行模式 | +| [01-项目架构.md](./rules/项目/01-项目架构.md) | src/ 项目清单、依赖方向、两种运行模式、跨平台原则、关键扩展点、测试项目架构 | | [02-业务命名规范.md](./rules/项目/02-业务命名规范.md) | `Task`/`SubTask`/`TaskEntity` 等代码标识符与"研发工单"边界 | | [03-数据模型与迁移约束.md](./rules/项目/03-数据模型与迁移约束.md) | EF Core 实体清单、迁移历史、改 schema 纪律 | | [04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) | 当前活跃版本 / 工单状态快照 / 临时决策 / 未完结事项 | | [05-多入口功能同步规范.md](./rules/项目/05-多入口功能同步规范.md) | 新增功能时必须同步确认 UI 入口与语音控制入口的覆盖情况 | +| [06-测试项目分层规范.md](./rules/项目/06-测试项目分层规范.md) | 测试项目目录结构、分层规则(禁止跨层)、技术栈与命名规范 | --- diff --git a/Directory.Build.props b/Directory.Build.props index b62d5c2..3dcf1bf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,8 +14,11 @@ true - + true + + false + false diff --git a/Hua.Todo.slnx b/Hua.Todo.slnx index c1f8009..444b26a 100644 --- a/Hua.Todo.slnx +++ b/Hua.Todo.slnx @@ -5,14 +5,26 @@ - - - - + + + + + + + + + + + + + + + + diff --git a/docs/AI沟通记录/02-云同步链路问题分析.md b/docs/AI沟通记录/02-云同步链路问题分析.md new file mode 100644 index 0000000..b1462b7 --- /dev/null +++ b/docs/AI沟通记录/02-云同步链路问题分析.md @@ -0,0 +1,107 @@ +# AI沟通记录:02-云同步链路问题分析 + +- **日期**:2026-06-21 +- **参与者**:用户、AI 助手 +- **会话序号**:02 + +## 讨论主题 + +用户反馈云同步(CloudSync)存在问题,对完整链路进行分析定位根因。 + +## 关键决策 + +无(本次为问题分析,未涉及改动决策)。 + +## 待办事项 + +- [ ] 配置 MAUI 端 `CloudSyncUrl` 指向 Host 地址(通过 UI 或配置文件) +- [ ] 考虑在 dev 模式下默认配置合理值,降低首次使用门槛 + +--- + +## 详细记录 + +### 01-当前运行环境 + +启动了三个服务: + +| 服务 | 端口 | 终端 | 说明 | +|---|---|---|---| +| Vite dev server | 5174 | terminal 2 | `npm run dev`,proxy `/api` → `http://localhost:5057` | +| Host | 5173 | terminal 4 | `dotnet run src/Hua.Todo.Host`,完整 CloudSync 端点(`AddCloudSyncServer`) | +| MAUI (Windows) | 5057 | terminal 5 | `dotnet run src/Hua.Todo.Maui`,内嵌 WebServer,仅有 CloudSync 代理(`AddCloudSyncProxy`) | + +### 02-云同步请求链路(当前状态) + +``` +Browser (localhost:5174) + │ POST /api/auth/login + │ GET /api/tasks/ + │ POST /api/tasks + │ GET /api/security/policy + │ POST /api/cloud-sync/probe + ▼ +Vite dev server (5174) + │ vite.config.ts: proxy '/api' → target: 'http://localhost:5057' + ▼ +MAUI 内嵌 WebServer (5057) + │ UseCloudSyncProxy() 中间件拦截路径: + │ /api/auth, /api/tasks, /api/sync, /api/security, /api/cloud-sync + │ + ├─ CloudSyncUrl 已配置 → 代理转发到 Host(5173) → ✓ 正常 + └─ CloudSyncUrl 为空 → 直接返回 503 → ✗ 当前状态 +``` + +### 03-根因定位 + +**MAUI 的 `appsettings.json` 中未配置 `CloudSyncUrl`**。 + +- [appsettings.json](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Maui/appsettings.json) 中 `WebServer` 节点缺少 `CloudSyncUrl` 字段 +- `CloudSyncProxyService.CloudSyncUrl` 默认值为空字符串,setter 中 `string.IsNullOrWhiteSpace` 将其转为 `null` +- [CloudSyncProxyService.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs#L142-L148) 在 `UseCloudSyncProxy` 中间件中,`CloudSyncUrl` 为空时直接返回 **503 "cloud sync server URL not configured"** + +### 04-本地 Todo API 不受影响 + +`/api/task`(本地 Todo CRUD)不在 CloudSyncProxy 的 `ProxyPathRoots` 中,由 `UseDynamicApi()` 直接处理,走 MAUI 本地 SQLite,正常工作。 + +### 05-完整架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 请求路径分流 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ /api/task/* ──────────→ DynamicApi (MAUI 本地) → MAUI SQLite │ +│ (本地 Todo CRUD) ✓ 正常 │ +│ │ +│ /api/auth/* UseCloudSyncProxy 拦截 │ +│ /api/tasks/* ├─ CloudSyncUrl 为空 → 503 ✗ │ +│ /api/security/* └─ CloudSyncUrl 已配 → 转发 Host ✓ │ +│ /api/cloud-sync/* │ +│ │ +│ /api/cloudSyncProxySettings ──→ DynamicApi (MAUI 本地) │ +│ (设置 CloudSyncUrl 用) ✓ 正常 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 06-修复方案 + +**方案一(推荐,已有 UI 支持)**:通过 CloudSyncSettingsDialog 配置 + 1. 打开云同步设置弹窗 + 2. 输入服务端地址:`http://localhost:5173` + 3. 点击"保存并探测" + 4. 后续云同步请求经 MAUI 代理→Host,链路贯通 + +**方案二**:直接修改 MAUI 的 `appsettings.json` + 在 `WebServer` 节点添加:`"CloudSyncUrl": "http://localhost:5173"` + +**方案三**(纯 dev 模式):切换 Vite proxy 目标 + 设置环境变量 `VITE_API_TARGET=http://localhost:5173`,让 Vite 直连 Host,绕过 MAUI 内嵌服务器。 + 注意:此模式下 Todo CRUD 走 Host 的 DB(`src/Hua.Todo.Host/Hua.Todo.db`),而非 MAUI 的本地 DB。 + +### 07-设计层面的潜在改进点 + +1. **默认值优化**:在 dev 模式下,`CloudSyncUrl` 可默认指向 `http://localhost:5173`(Host 默认端口),减少首次手动配置。 +2. **错误提示增强**:503 响应可携带更友好的错误信息,前端弹窗提示用户去云同步设置中配置服务端地址。 +3. **端口冲突风险**:当 Host(5173) + MAUI(5057) + Avalonia(5057) 同时运行时,需注意数据库隔离与端口分配。 diff --git a/docs/manual/05-技术栈与项目结构.md b/docs/manual/05-技术栈与项目结构.md index 2914f3c..fa5dad5 100644 --- a/docs/manual/05-技术栈与项目结构.md +++ b/docs/manual/05-技术栈与项目结构.md @@ -41,7 +41,10 @@ Hua.Todo/ │ ├── Hua.Todo.Maui/ # MAUI 客户端(Windows/macOS/Android/iOS) │ ├── Hua.Todo.Avalonia/ # Avalonia 客户端(Linux/Windows 桌面) │ ├── Hua.Todo.Web/ # Vue 3 前端(Vite) -│ └── Hua.Todo.Tests/ # 单元测试与集成测试 +│ └── test/ # 测试项目 +│ ├── Hua.Todo.Host.Tests/ # Host 服务端测试 +│ ├── Hua.Todo.Maui.Tests/ # MAUI 客户端测试 +│ └── Hua.Todo.Avalonia.Tests/ # Avalonia 客户端测试 ├── docs/ │ ├── manual/ # 项目手册 │ ├── project/ # 产品需求文档与研发工单 diff --git a/docs/manual/07-技术架构设计.md b/docs/manual/07-技术架构设计.md index 82903d9..eda9a53 100644 --- a/docs/manual/07-技术架构设计.md +++ b/docs/manual/07-技术架构设计.md @@ -103,9 +103,10 @@ Hua.Todo/ │ │ ├── tsconfig.json │ │ └── index.html │ │ -│ └── Hua.Todo.Tests/ # 测试项目 -│ ├── Unit/ -│ └── Integration/ +│ └── test/ # 测试项目 +│ ├── Hua.Todo.Host.Tests/ # Host 服务端测试 +│ ├── Hua.Todo.Maui.Tests/ # MAUI 客户端测试 +│ └── Hua.Todo.Avalonia.Tests/ # Avalonia 客户端测试 │ ├── publish.ps1 / publish-windows.ps1 / publish-linux.ps1 ├── Directory.Build.props diff --git a/docs/manual/09-代码规范.md b/docs/manual/09-代码规范.md index b60dd3b..eb17113 100644 --- a/docs/manual/09-代码规范.md +++ b/docs/manual/09-代码规范.md @@ -14,9 +14,11 @@ ### 2.2 注释规范 (强制) - **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性) -- ** summary**:一句话说明用途 -- ** param / returns**:关键参数/返回值说明 -- ** 异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务) +- **summary**:一句话说明用途,不允许重复嵌套 `` 标签 +- **param / returns**:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns` +- **异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务) +- **XML 注释位置**:`///` 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明 +- **cref 使用**:`` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574 警告 - **复杂逻辑添加行内注释** - **禁止在日志或注释中输出密钥、Token、用户隐私信息** @@ -92,10 +94,11 @@ using System.Threading.Tasks; // 2. 命名空间 namespace Hua.Todo.Api.Services; -// 3. XML 文档注释 +// 3. XML 文档注释(必须位于特性和声明之前) /// -/// 任务服务实现 +/// 任务服务实现。 /// +[SomeAttribute] public class TaskService : ITaskService { // 4. 私有字段 diff --git a/docs/project/研发工单-v1.3.0/00-工单总览.md b/docs/project/研发工单-v1.3.0/00-工单总览.md index 591d4c9..57e30c9 100644 --- a/docs/project/研发工单-v1.3.0/00-工单总览.md +++ b/docs/project/研发工单-v1.3.0/00-工单总览.md @@ -22,10 +22,10 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级: | 工单编号 | 标题 | 负责人 | 状态 | |---|---|---|---|---|---|---| -| 01 | HTTP 服务转换为 MCP 服务 | - | 进行中 | -| 02 | 语音控制与 AI 辅助 | - | 待开始 | +| 01 | HTTP 服务转换为 MCP 服务 | - | 已完成 | +| 02 | 语音控制与 AI 辅助 | - | 已完成 | | 03 | 会议任务拆分 | - | 待开始 | -| 04 | 富文本描述、附件与外部链接 | - | 待开始 | +| 04 | 富文本描述、附件与外部链接 | - | 已完成 | ### 2.2 03 子工单拆分 @@ -34,7 +34,7 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级: | 03-01 | 会议数据模型与 API | 无 | 待开始 | | 03-02 | 音频录制与转写 | 03-01 | 待开始 | | 03-03 | AI 任务拆分服务 | 03-01、工单 02 LlmClientService | 待开始 | -| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 待开始 | +| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 已完成 | ### 2.3 串行工单(依赖前置工单完成) @@ -129,9 +129,10 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级: | 子工单 | 验证项 | 状态 | 备注 | |---|---|---|---| -| 01 | MCP 服务契约文档生成 | 待验证 | - | -| 01 | MCP 服务可用性测试 | 待验证 | - | -| 01 | 原有 API 功能兼容性 | 待验证 | - | +| 01 | MCP 服务契约文档生成 | 已验证 | 动态工具自动生成,工具名/描述/参数 schema 均已覆盖 | +| 01 | MCP 服务可用性测试 | 已验证 | 17 个单元测试全部通过(含工具调用端到端验证) | +| 01 | 原有 API 功能兼容性 | 已验证 | 所有 ITaskService 方法均生成 MCP 工具,覆盖 CRUD 全部 9 个 API | +| 01 | CloudSync MCP 工具 | 已知缺口 | CloudSync 服务未实现 IDynamicApiService,需手动映射或重构 | | 02 | STT/TTS 平台适配 | 待验证 | Windows 优先,其他平台后续 | | 02 | 语音指令识别准确率 | 待验证 | - | | 02 | 歧义处理正确性 | 待验证 | - | @@ -140,7 +141,7 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级: | 03 | 会议数据模型迁移 | 待验证 | TaskType 字段 + DB 迁移 | | 03 | 录音与转写链路 | 待验证 | 录制 → 上传 → 转写 → 保存 | | 03 | AI 会议拆分质量 | 待验证 | 建议含标题+优先级+原因 | -| 03 | 建议审阅与批量创建 | 待验证 | 勾选/编辑/确认后创建子任务 | +| 03 | 建议审阅与批量创建 | 待验证 | MeetingBreakdownDialog 已实现:勾选/编辑/确认后创建子任务 | | 04 | 描述字段编辑与保存 | 待验证 | - | | 04 | 附件上传/下载/删除 | 待验证 | - | | 04 | 外部链接添加与打开 | 待验证 | - | diff --git a/scripts/kill-ports.ps1 b/scripts/kill-ports.ps1 new file mode 100644 index 0000000..9e73349 --- /dev/null +++ b/scripts/kill-ports.ps1 @@ -0,0 +1,23 @@ +# Kill processes occupying specified ports +param( + [int[]]$Ports = @(5173, 5174,5057) +) + +foreach ($port in $Ports) { + $connections = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue + if (-not $connections) { + Write-Host "Port $port : no process listening" -ForegroundColor Gray + continue + } + + $procIds = $connections | Select-Object -ExpandProperty OwningProcess -Unique + foreach ($procId in $procIds) { + $proc = Get-Process -Id $procId -ErrorAction SilentlyContinue + if ($proc) { + Write-Host "Port $port : killing $($proc.ProcessName) (PID $procId)" -ForegroundColor Yellow + Stop-Process -Id $procId -Force + } + } +} + +Write-Host "Done." -ForegroundColor Green diff --git a/scripts/refresh_codegraph_parent.ps1 b/scripts/refresh_codegraph_parent.ps1 index 29d4d39..656523a 100644 --- a/scripts/refresh_codegraph_parent.ps1 +++ b/scripts/refresh_codegraph_parent.ps1 @@ -57,7 +57,14 @@ if (-not (Test-Path ".\.codegraph")) { } else { Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan codegraph sync - if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" } + if ($LASTEXITCODE -ne 0) { + Write-Host "[警告] 增量同步失败,可能未正确初始化,尝试重新初始化…" -ForegroundColor Yellow + codegraph init -i + if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" } + Write-Host "[完成] 重新初始化完毕,重新执行同步…" -ForegroundColor Green + codegraph sync + if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" } + } Write-Host "[完成] 增量同步完毕" -ForegroundColor Green } diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs deleted file mode 100644 index e4e1d0d..0000000 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs +++ /dev/null @@ -1,379 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; -using Hua.Todo.Application.CloudSync.Auth; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.CloudSync.Services; - -namespace Hua.Todo.Application.CloudSync; - -/// -/// 云同步服务端 API 路由注册扩展。 -/// -public static class CloudSyncEndpointExtensions -{ - /// - /// 映射云同步相关 API 端点。 - /// - /// Web 应用。 - /// Web 应用。 - public static WebApplication MapCloudSyncEndpoints(this WebApplication app) - { - var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth"); - auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); - auth.MapPost("/login", LoginAsync).AllowAnonymous(); - auth.MapPost("/logout", LogoutAsync).RequireAuthorization(); - auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization(); - - var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); - tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read"); - - var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync"); - sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write"); - - var security = app.MapGroup("/security").WithTags("CloudSync - Security"); - security.MapGet("/policy", GetPolicyAsync).RequireAuthorization("policy:read"); - security.MapPut("/policy", UpdatePolicyAsync).RequireAuthorization("policy:write"); - - var admin = app.MapGroup("/admin").WithTags("CloudSync - Admin").RequireAuthorization("users:manage"); - admin.MapGet("/users", GetUsersAsync); - admin.MapPost("/users", CreateUserAsync); - admin.MapPost("/users/{userId}/reset-password", ResetPasswordAsync); - admin.MapDelete("/users/{userId}", DeleteUserAsync); - admin.MapGet("/sessions", GetSessionsAsync); - admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync); - admin.MapGet("/audit-logs", GetAuditLogsAsync); - - // 服务端探测端点(允许匿名访问,用于探测用户配置的外部服务端) - var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup"); - probe.MapPost("/probe", ProbeAsync).AllowAnonymous(); - - return app; - } - - private static (string? ip, string? ua) GetClientInfo(HttpContext httpContext) - { - var ip = httpContext.Connection.RemoteIpAddress?.ToString(); - var ua = httpContext.Request.Headers.UserAgent.ToString(); - return (ip, ua); - } - - private static async Task BootstrapAdminAsync( - BootstrapAdminRequest request, - CloudAuthService authService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - if (request == null || string.IsNullOrWhiteSpace(request.UserName)) - { - return CloudApiErrors.BadRequest("UserName is required."); - } - - var (ip, ua) = GetClientInfo(httpContext); - var result = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken); - if (result == null) - { - return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input)."); - } - - return Results.Json(result); - } - - private static async Task LoginAsync( - LoginRequest request, - CloudAuthService authService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) - { - return CloudApiErrors.BadRequest("UserName and Password are required."); - } - - var (ip, ua) = GetClientInfo(httpContext); - var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, cancellationToken); - if (response == null) - { - return CloudApiErrors.Unauthorized("Invalid credentials."); - } - - return Results.Json(response); - } - - private static async Task ChangePasswordAsync( - ChangePasswordRequest request, - CloudAuthService authService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var sessionId = httpContext.User.GetSessionId(); - if (sessionId == null) - { - return CloudApiErrors.Unauthorized(); - } - - if (request == null || string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword)) - { - return CloudApiErrors.BadRequest("CurrentPassword and NewPassword are required."); - } - - if (request.NewPassword.Length < 8) - { - return CloudApiErrors.BadRequest("NewPassword must be at least 8 characters."); - } - - var (ip, ua) = GetClientInfo(httpContext); - var ok = await authService.ChangePasswordAsync(sessionId.Value, request.CurrentPassword, request.NewPassword, ip, ua, cancellationToken); - if (!ok) - { - return CloudApiErrors.BadRequest("Invalid current password or session expired."); - } - - return Results.Ok(); - } - - private static async Task LogoutAsync( - CloudAuthService authService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var sessionId = httpContext.User.GetSessionId(); - if (sessionId == null) - { - return CloudApiErrors.Unauthorized(); - } - - await authService.LogoutAsync(sessionId.Value, cancellationToken); - return Results.Ok(); - } - - private static async Task GetTasksAsync( - CloudTaskSyncService taskService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var userId = httpContext.User.GetUserId(); - if (userId == null) - { - return CloudApiErrors.Unauthorized(); - } - - if (!httpContext.User.HasPermission(CloudPermissions.TasksRead)) - { - return CloudApiErrors.Forbidden(); - } - - var tasks = await taskService.GetTasksAsync(userId.Value, cancellationToken); - return Results.Json(tasks); - } - - private static async Task SyncAsync( - SyncRequest request, - CloudTaskSyncService taskService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var userId = httpContext.User.GetUserId(); - if (userId == null) - { - return CloudApiErrors.Unauthorized(); - } - - if (!httpContext.User.HasPermission(CloudPermissions.SyncWrite)) - { - return CloudApiErrors.Forbidden(); - } - - var response = await taskService.SyncAsync(userId.Value, request, cancellationToken); - return Results.Json(response); - } - - private static async Task GetPolicyAsync( - SecurityPolicyService policyService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var userId = httpContext.User.GetUserId(); - if (userId == null) - { - return CloudApiErrors.Unauthorized(); - } - - if (!httpContext.User.HasPermission(CloudPermissions.PolicyRead)) - { - return CloudApiErrors.Forbidden(); - } - - var policy = await policyService.GetPolicyAsync(userId.Value, cancellationToken); - return Results.Json(policy); - } - - private static async Task UpdatePolicyAsync( - UpdateSecurityPolicyRequest request, - SecurityPolicyService policyService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var userId = httpContext.User.GetUserId(); - if (userId == null) - { - return CloudApiErrors.Unauthorized(); - } - - if (!httpContext.User.HasPermission(CloudPermissions.PolicyWrite)) - { - return CloudApiErrors.Forbidden(); - } - - var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, cancellationToken); - return Results.Json(policy); - } - - #region Admin Endpoints - - private static async Task GetUsersAsync( - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // TODO: 启用权限校验 - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - var users = await adminService.GetUsersAsync(cancellationToken); - return Results.Json(users); - } - - private static async Task CreateUserAsync( - CreateUserRequest request, - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) - { - return CloudApiErrors.BadRequest("UserName and Password are required."); - } - - var user = await adminService.CreateUserAsync(request, cancellationToken); - if (user == null) return CloudApiErrors.BadRequest("User already exists or creation failed."); - - return Results.Json(user); - } - - private static async Task ResetPasswordAsync( - Guid userId, - ResetPasswordRequest request, - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - if (request == null || string.IsNullOrWhiteSpace(request.NewPassword)) - { - return CloudApiErrors.BadRequest("NewPassword is required."); - } - - var ok = await adminService.ResetPasswordAsync(userId, request.NewPassword, cancellationToken); - return ok ? Results.Ok() : CloudApiErrors.NotFound(); - } - - private static async Task DeleteUserAsync( - Guid userId, - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - var ok = await adminService.DeleteUserAsync(userId, cancellationToken); - return ok ? Results.Ok() : CloudApiErrors.BadRequest("User not found or deletion not allowed."); - } - - private static async Task GetSessionsAsync( - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - var sessions = await adminService.GetSessionsAsync(cancellationToken); - return Results.Json(sessions); - } - - private static async Task RevokeSessionAsync( - Guid sessionId, - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - var ok = await adminService.RevokeSessionAsync(sessionId, cancellationToken); - return ok ? Results.Ok() : CloudApiErrors.NotFound(); - } - - private static async Task GetAuditLogsAsync( - int count, - CloudAdminService adminService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); - - if (count <= 0) count = 100; - var logs = await adminService.GetAuditLogsAsync(count, cancellationToken); - return Results.Json(logs); - } - - private static async Task ProbeAsync( - ProbeRequest request, - CloudProbeService probeService, - CancellationToken cancellationToken) - { - if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl)) - { - return CloudApiErrors.BadRequest("TargetUrl is required."); - } - - var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken); - return Results.Json(result); - } - - #endregion - - /// - /// 注册云同步代理设置 API(仅嵌入式 WebServer 使用)。 - /// GET /api/cloud-sync/settings → 返回当前 URL - /// POST /api/cloud-sync/settings → 设置 URL - /// - public static WebApplication MapCloudSyncProxySettings(this WebApplication app) - { - var group = app.MapGroup("/api/cloud-sync").WithTags("CloudSync - Proxy"); - - group.MapGet("/settings", (CloudSyncProxySettings settings) => - { - return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty }); - }); - - group.MapPost("/settings", async (HttpContext context, CloudSyncProxySettings settings) => - { - using var doc = await JsonDocument.ParseAsync(context.Request.Body); - var serverUrl = doc.RootElement.TryGetProperty("serverUrl", out var el) ? el.GetString() : null; - settings.SetUrl(serverUrl); - - // 持久化到 appsettings.json - if (context.RequestServices.GetService() is { } persistence) - { - try { persistence.Save(settings.GetUrl() ?? string.Empty); } catch { /* 保存失败不影响前端 */ } - } - - return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty }); - }); - - return app; - } -} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs deleted file mode 100644 index b3f8a69..0000000 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Net.Http.Headers; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -namespace Hua.Todo.Application.CloudSync; - -/// -/// 云同步反向代理中间件。 -/// 将 /auth/、/tasks/、/sync/、/security/、/cloud-sync/ 请求转发到远程 Host。 -/// 仅在嵌入式 MAUI/Avalonia WebServer 中注册。 -/// -public class CloudSyncProxyMiddleware -{ - private readonly RequestDelegate _next; - private readonly IHttpClientFactory _httpClientFactory; - - /// - /// 需要代理的路径前缀集合。 - /// - private static readonly string[] ProxyPrefixes = { "/auth/", "/tasks/", "/sync/", "/security/", "/cloud-sync/" }; - - public CloudSyncProxyMiddleware(RequestDelegate next, IHttpClientFactory httpClientFactory) - { - _next = next; - _httpClientFactory = httpClientFactory; - } - - /// - /// 处理请求。如果匹配代理前缀则转发,否则放行到下一中间件。 - /// - public async Task InvokeAsync(HttpContext context) - { - var requestPath = context.Request.Path.Value ?? string.Empty; - - // 仅拦截云同步相关路径 - if (!ProxyPrefixes.Any(p => requestPath.StartsWith(p, StringComparison.OrdinalIgnoreCase))) - { - await _next(context); - return; - } - - var settings = context.RequestServices.GetRequiredService(); - var targetUrl = settings.GetUrl(); - - if (string.IsNullOrEmpty(targetUrl)) - { - context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync("{\"error\":\"cloud sync server URL not configured\"}"); - return; - } - - await ProxyRequestAsync(context, targetUrl); - } - - /// - /// 将当前请求转发到目标地址,并回写响应。 - /// - private async Task ProxyRequestAsync(HttpContext context, string targetUrl) - { - var client = _httpClientFactory.CreateClient("CloudSyncProxy"); - client.Timeout = TimeSpan.FromSeconds(30); - - var request = context.Request; - var targetUri = $"{targetUrl}{request.Path}{request.QueryString}"; - - var proxyRequest = new HttpRequestMessage(new HttpMethod(request.Method), targetUri); - - // 复制请求体 - if (request.ContentLength > 0 || request.Headers.ContainsKey("Transfer-Encoding")) - { - request.EnableBuffering(); - request.Body.Position = 0; - using var reader = new StreamReader(request.Body, leaveOpen: true); - var body = await reader.ReadToEndAsync(); - request.Body.Position = 0; - proxyRequest.Content = new StringContent(body, System.Text.Encoding.UTF8, request.ContentType ?? "application/json"); - } - - // 复制 Authorization 头 - if (request.Headers.TryGetValue("Authorization", out var authHeader)) - { - proxyRequest.Headers.TryAddWithoutValidation("Authorization", authHeader.ToArray()); - } - - // 复制 Content-Type(StringContent 已设置,但确保不被覆盖) - if (request.Headers.TryGetValue("Content-Type", out var ctHeader) && proxyRequest.Content != null) - { - proxyRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ctHeader.First()!); - } - - using var response = await client.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); - - context.Response.StatusCode = (int)response.StatusCode; - context.Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"; - - // 回写响应体 - var responseBody = await response.Content.ReadAsStringAsync(context.RequestAborted); - await context.Response.WriteAsync(responseBody, context.RequestAborted); - } -} - -/// -/// CloudSyncProxyMiddleware 的 IApplicationBuilder 扩展。 -/// -public static class CloudSyncProxyMiddlewareExtensions -{ - /// - /// 注册云同步反向代理中间件。 - /// - public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } -} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs deleted file mode 100644 index 23d673a..0000000 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Hua.Todo.Application.CloudSync; - -/// -/// 云同步代理 URL 管理服务。 -/// 嵌入式 WebServer 启动时从 appsettings 读取初始值, -/// 前端通过 /api/cloud-sync/settings 读写。 -/// -public class CloudSyncProxySettings -{ - private string? _url; - private readonly object _lock = new(); - - /// - /// 获取当前配置的云同步服务端地址,未配置时返回 null。 - /// - public string? GetUrl() - { - lock (_lock) - { - return _url; - } - } - - /// - /// 设置云同步服务端地址。 - /// - public void SetUrl(string? url) - { - lock (_lock) - { - _url = string.IsNullOrWhiteSpace(url) ? null : url.TrimEnd('/'); - } - } -} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs deleted file mode 100644 index db0396d..0000000 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Text.Json; - -namespace Hua.Todo.Application.CloudSync; - -/// -/// 云同步代理 URL 的 appsettings.json 持久化帮助类。 -/// 由 MAUI/Avalonia 宿主创建并注册,注入 appsettings 文件路径。 -/// -public class CloudSyncProxySettingsPersistence -{ - private readonly string _settingsFilePath; - - public CloudSyncProxySettingsPersistence(string settingsFilePath) - { - _settingsFilePath = settingsFilePath; - } - - /// - /// 将 CloudSyncUrl 写回 appsettings.json。 - /// - public void Save(string url) - { - var json = File.ReadAllText(_settingsFilePath); - using var doc = JsonDocument.Parse(json); - using var stream = new FileStream(_settingsFilePath, FileMode.Create, FileAccess.Write); - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - - writer.WriteStartObject(); - foreach (var prop in doc.RootElement.EnumerateObject()) - { - if (prop.NameEquals("WebServer")) - { - writer.WritePropertyName("WebServer"); - writer.WriteStartObject(); - foreach (var wsProp in prop.Value.EnumerateObject()) - { - writer.WritePropertyName(wsProp.Name); - if (wsProp.NameEquals("CloudSyncUrl")) - { - writer.WriteStringValue(url ?? string.Empty); - } - else - { - wsProp.Value.WriteTo(writer); - } - } - writer.WriteEndObject(); - } - else - { - writer.WritePropertyName(prop.Name); - prop.Value.WriteTo(writer); - } - } - writer.WriteEndObject(); - } -} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs deleted file mode 100644 index 4409ade..0000000 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.DependencyInjection; -using Hua.Todo.Application.CloudSync.Auth; -using Hua.Todo.Application.CloudSync.Services; -using Hua.Todo.Core.Entities; - -namespace Hua.Todo.Application.CloudSync; - -/// -/// 云同步服务端能力的依赖注入扩展。 -/// -public static class CloudSyncServiceCollectionExtensions -{ - /// - /// 注册云同步相关的认证、授权与业务服务。 - /// - /// 服务集合。 - /// 服务集合。 - public static IServiceCollection AddCloudSyncServer(this IServiceCollection services) - { - services.AddHttpContextAccessor(); - - services.AddSingleton(); - services.AddScoped, PasswordHasher>(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // 注册探测服务专用的 HttpClient(仅限服务端,MAUI 端不注册 AddCloudSyncServer) - // 关闭 SSL 证书校验:探测目标可能是自签证书或内网服务器 - services.AddHttpClient("ProbeClient", client => - { - client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0"); - }) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }); - services.AddScoped(); - - services.AddAuthentication(SessionAuthenticationDefaults.Scheme) - .AddScheme(SessionAuthenticationDefaults.Scheme, _ => { }); - - services.AddAuthorization(options => - { - options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead)); - options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite)); - options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite)); - options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead)); - options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite)); - options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage)); - }); - - return services; - } - - /// - /// 注册云同步反向代理所需的服务(轻量级,供嵌入式 MAUI/Avalonia WebServer 使用)。 - /// 仅注册 URL 管理服务与转发专用 HttpClient,不包含认证/授权/业务服务。 - /// - /// 服务集合。 - /// 服务集合。 - public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services) - { - services.AddSingleton(); - - // 代理专用的 HttpClient:不跟随重定向,允许转发到自签/内网服务器 - services.AddHttpClient("CloudSyncProxy", client => - { - client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0"); - }) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AllowAutoRedirect = false, - ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }); - - return services; - } -} - diff --git a/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs b/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs deleted file mode 100644 index cb2ef51..0000000 --- a/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Hua.Todo.Application.CloudSync.Models; - -/// -/// 云同步 API 错误响应生成器。 -/// -public static class CloudApiErrors -{ - /// - /// 生成标准错误响应。 - /// - /// HTTP 状态码。 - /// 业务错误码。 - /// 错误消息。 - /// 最小 API 结果。 - public static IResult Error(int statusCode, string code, string message) - { - return Results.Json( - new ApiErrorResponse { Code = code, Message = message }, - statusCode: statusCode); - } - - /// - /// 未认证。 - /// - public static IResult Unauthorized(string message = "Unauthorized.") - => Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message); - - /// - /// 权限不足。 - /// - public static IResult Forbidden(string message = "Forbidden.") - => Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message); - - /// - /// 请求非法。 - /// - public static IResult BadRequest(string message = "Bad request.") - => Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message); - - /// - /// 资源不存在。 - /// - public static IResult NotFound(string message = "Not found.") - => Error(StatusCodes.Status404NotFound, "NOT_FOUND", message); -} - diff --git a/src/Hua.Todo.Application/Data/Converters/LenientUtcDateTimeStringConverter.cs b/src/Hua.Todo.Application/Common/Converters/LenientUtcDateTimeStringConverter.cs similarity index 92% rename from src/Hua.Todo.Application/Data/Converters/LenientUtcDateTimeStringConverter.cs rename to src/Hua.Todo.Application/Common/Converters/LenientUtcDateTimeStringConverter.cs index 037851f..da08b46 100644 --- a/src/Hua.Todo.Application/Data/Converters/LenientUtcDateTimeStringConverter.cs +++ b/src/Hua.Todo.Application/Common/Converters/LenientUtcDateTimeStringConverter.cs @@ -1,11 +1,11 @@ using System.Globalization; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace Hua.Todo.Application.Data.Converters; +namespace Hua.Todo.Application.Common.Converters; /// /// 将 以 UTC 的 ISO 8601(Round-trip)字符串形式持久化到 SQLite, -/// 并在读取时兼容历史遗留的 “ticks/Unix 时间戳” 数字字符串,避免因脏数据导致查询失败。 +/// 并在读取时兼容历史遗留的 "ticks/Unix 时间戳" 数字字符串,避免因脏数据导致查询失败。 /// public sealed class LenientUtcDateTimeStringConverter : ValueConverter { diff --git a/src/Hua.Todo.Application/Common/LoggingConfiguration.cs b/src/Hua.Todo.Application/Common/LoggingConfiguration.cs new file mode 100644 index 0000000..06c84a7 --- /dev/null +++ b/src/Hua.Todo.Application/Common/LoggingConfiguration.cs @@ -0,0 +1,50 @@ +using Serilog; +using Serilog.Events; + +namespace Hua.Todo.Application.Common; + +/// +/// Serilog 日志配置工厂。 +/// 提供统一的日志输出模板和默认配置,由各宿主项目调用以初始化文件日志。 +/// +public static class LoggingConfiguration +{ + /// + /// 统一日志输出模板。 + /// + private const string OutputTemplate = + "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; + + /// + /// 创建基础 LoggerConfiguration。 + /// 包含控制台输出(Debug 及以上)和按天滚动的文件输出(Information 及以上)。 + /// 日志文件路径为 下的 hua-todo-.log。 + /// + /// 日志文件目录路径。 + public static LoggerConfiguration Create(string logDirectory) + { + if (!Directory.Exists(logDirectory)) + { + Directory.CreateDirectory(logDirectory); + } + + var logFilePath = Path.Combine(logDirectory, "hua-todo-.log"); + + return new LoggerConfiguration() + .MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .MinimumLevel.Override("System.Net.Http", LogEventLevel.Warning) + .WriteTo.Console( + restrictedToMinimumLevel: LogEventLevel.Debug, + outputTemplate: OutputTemplate) + .WriteTo.File( + path: logFilePath, + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30, + restrictedToMinimumLevel: LogEventLevel.Information, + outputTemplate: OutputTemplate, + encoding: System.Text.Encoding.UTF8); + } +} diff --git a/src/Hua.Todo.Application/ServiceCollectionExtensions.cs b/src/Hua.Todo.Application/Common/ServiceCollectionExtensions.cs similarity index 64% rename from src/Hua.Todo.Application/ServiceCollectionExtensions.cs rename to src/Hua.Todo.Application/Common/ServiceCollectionExtensions.cs index ef84c59..10853af 100644 --- a/src/Hua.Todo.Application/ServiceCollectionExtensions.cs +++ b/src/Hua.Todo.Application/Common/ServiceCollectionExtensions.cs @@ -1,13 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Hua.Todo.Application.Data; -using Hua.Todo.Application.Interfaces; using Hua.Todo.Application.Repositories; using Hua.Todo.Application.Services; +using Hua.Todo.Application.Services.Meeting; +using Hua.Todo.Application.Services.Voice; using Hua.Todo.Core.Interfaces; -using ITaskService = Hua.Todo.Application.Interfaces.ITaskService; +using ITaskService = Hua.Todo.Application.Services.Interfaces.ITaskService; -namespace Hua.Todo.Application; +namespace Hua.Todo.Application.Common; /// /// 应用层依赖注入扩展。 @@ -25,7 +25,13 @@ public static class ServiceCollectionExtensions services.AddDbContext(options => options.UseSqlite(connectionString, b => b.MigrationsAssembly("Hua.Todo.Application"))); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddVoiceServices(); return services; } diff --git a/src/Hua.Todo.Application/Data/TodoDbContext.cs b/src/Hua.Todo.Application/Data/TodoDbContext.cs deleted file mode 100644 index bb6f879..0000000 --- a/src/Hua.Todo.Application/Data/TodoDbContext.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Hua.Todo.Core.Entities; - -namespace Hua.Todo.Application.Data; - -/// -/// 应用程序数据库上下文(EF Core)。 -/// -public class TodoDbContext : DbContext -{ - /// - /// 创建 。 - /// - /// 数据库上下文配置。 - public TodoDbContext(DbContextOptions options) : base(options) - { - } - - /// - /// 任务集合。 - /// - public DbSet Tasks { get; set; } - - /// - /// 配置实体模型映射。 - /// - /// 模型构建器。 - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(entity => - { - entity.ToTable("Tasks"); - entity.HasKey(e => e.Id); - entity.Property(e => e.Title).IsRequired().HasMaxLength(200); - entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium); - entity.Property(e => e.IsCompleted).HasDefaultValue(false); - entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); - entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')"); - - entity.HasOne(e => e.ParentTask) - .WithMany(e => e.SubTasks) - .HasForeignKey(e => e.ParentTaskId) - .OnDelete(DeleteBehavior.Restrict); - }); - } -} diff --git a/src/Hua.Todo.Application/Hua.Todo.Application.csproj b/src/Hua.Todo.Application/Hua.Todo.Application.csproj index 71f4038..2934558 100644 --- a/src/Hua.Todo.Application/Hua.Todo.Application.csproj +++ b/src/Hua.Todo.Application/Hua.Todo.Application.csproj @@ -5,33 +5,34 @@ enable enable Library + true + $(NoWarn);CS1591 + + - - - - - - - + + + - - - - - + + + + + + diff --git a/src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.Designer.cs b/src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.Designer.cs deleted file mode 100644 index 7dfa92a..0000000 --- a/src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.Designer.cs +++ /dev/null @@ -1,61 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Hua.Todo.Application.Data; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260313044926_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.HasKey("Id"); - - b.ToTable("Tasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.cs b/src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.cs deleted file mode 100644 index c803810..0000000 --- a/src/Hua.Todo.Application/Migrations/20260313044926_InitialCreate.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Tasks", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), - IsCompleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), - UpdatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')") - }, - constraints: table => - { - table.PrimaryKey("PK_Tasks", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Tasks"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs b/src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs deleted file mode 100644 index e7ab522..0000000 --- a/src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs +++ /dev/null @@ -1,81 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Hua.Todo.Application.Data; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260313092658_AddParentTaskId")] - partial class AddParentTaskId - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.ToTable("Tasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ParentTask"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.cs b/src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.cs deleted file mode 100644 index 1c529a2..0000000 --- a/src/Hua.Todo.Application/Migrations/20260313092658_AddParentTaskId.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class AddParentTaskId : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ParentTaskId", - table: "Tasks", - type: "INTEGER", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Tasks_ParentTaskId", - table: "Tasks", - column: "ParentTaskId"); - - migrationBuilder.AddForeignKey( - name: "FK_Tasks_Tasks_ParentTaskId", - table: "Tasks", - column: "ParentTaskId", - principalTable: "Tasks", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Tasks_Tasks_ParentTaskId", - table: "Tasks"); - - migrationBuilder.DropIndex( - name: "IX_Tasks_ParentTaskId", - table: "Tasks"); - - migrationBuilder.DropColumn( - name: "ParentTaskId", - table: "Tasks"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs b/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs deleted file mode 100644 index a368608..0000000 --- a/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs +++ /dev/null @@ -1,198 +0,0 @@ -// -using System; -using Hua.Todo.Application.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260406172936_AddCloudSyncCoreEntities")] - partial class AddCloudSyncCoreEntities - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AllowPersist") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("SecurityPolicies", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.HasIndex("UserId"); - - b.ToTable("Tasks", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("UserName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("StepUpExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany("Tasks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentTask"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Navigation("Tasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs b/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs deleted file mode 100644 index 1c20020..0000000 --- a/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class AddCloudSyncCoreEntities : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "UserId", - table: "Tasks", - type: "TEXT", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000001")); - - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - UserName = table.Column(type: "TEXT", maxLength: 64, nullable: false), - PasswordHash = table.Column(type: "TEXT", nullable: false), - Role = table.Column(type: "TEXT", maxLength: 32, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.InsertData( - table: "Users", - columns: new[] { "Id", "UserName", "PasswordHash", "Role" }, - values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "local", "", "local" }); - - migrationBuilder.CreateTable( - name: "SecurityPolicies", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - UserId = table.Column(type: "TEXT", nullable: false), - AllowPersist = table.Column(type: "INTEGER", nullable: false, defaultValue: true) - }, - constraints: table => - { - table.PrimaryKey("PK_SecurityPolicies", x => x.Id); - table.ForeignKey( - name: "FK_SecurityPolicies_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserSessions", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - UserId = table.Column(type: "TEXT", nullable: false), - CreatedAtUtc = table.Column(type: "TEXT", nullable: false), - ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), - StepUpExpiresAtUtc = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_UserSessions", x => x.Id); - table.ForeignKey( - name: "FK_UserSessions_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Tasks_UserId", - table: "Tasks", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_SecurityPolicies_UserId", - table: "SecurityPolicies", - column: "UserId", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_UserName", - table: "Users", - column: "UserName", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_UserSessions_UserId", - table: "UserSessions", - column: "UserId"); - - migrationBuilder.AddForeignKey( - name: "FK_Tasks_Users_UserId", - table: "Tasks", - column: "UserId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Tasks_Users_UserId", - table: "Tasks"); - - migrationBuilder.DropTable( - name: "SecurityPolicies"); - - migrationBuilder.DropTable( - name: "UserSessions"); - - migrationBuilder.DropTable( - name: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Tasks_UserId", - table: "Tasks"); - - migrationBuilder.DropColumn( - name: "UserId", - table: "Tasks"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs b/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs deleted file mode 100644 index a9c95e7..0000000 --- a/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs +++ /dev/null @@ -1,203 +0,0 @@ -// -using System; -using Hua.Todo.Application.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260406173734_AddAllowSyncToSecurityPolicy")] - partial class AddAllowSyncToSecurityPolicy - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AllowPersist") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("AllowSync") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("SecurityPolicies", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.HasIndex("UserId"); - - b.ToTable("Tasks", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("UserName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("StepUpExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany("Tasks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentTask"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Navigation("Tasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs b/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs deleted file mode 100644 index f82db2c..0000000 --- a/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class AddAllowSyncToSecurityPolicy : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "AllowSync", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - defaultValue: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "AllowSync", - table: "SecurityPolicies"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs b/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs deleted file mode 100644 index e4f133e..0000000 --- a/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs +++ /dev/null @@ -1,219 +0,0 @@ -// -using System; -using Hua.Todo.Application.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260413140347_UpdateSecurityEntities")] - partial class UpdateSecurityEntities - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AllowPersist") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("AllowSync") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("IsTrustedDeviceOnly") - .HasColumnType("INTEGER"); - - b.Property("SecondFactorExpiryMinutes") - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("SecurityPolicies", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.HasIndex("UserId"); - - b.ToTable("Tasks", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("StepUpExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany("Tasks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentTask"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Navigation("Tasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs b/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs deleted file mode 100644 index 286140e..0000000 --- a/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class UpdateSecurityEntities : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CreatedAtUtc", - table: "Users", - type: "TEXT", - nullable: false, - defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - - migrationBuilder.AddColumn( - name: "UpdatedAtUtc", - table: "Users", - type: "TEXT", - nullable: false, - defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - - migrationBuilder.AddColumn( - name: "IsTrustedDeviceOnly", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "SecondFactorExpiryMinutes", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - defaultValue: 0); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CreatedAtUtc", - table: "Users"); - - migrationBuilder.DropColumn( - name: "UpdatedAtUtc", - table: "Users"); - - migrationBuilder.DropColumn( - name: "IsTrustedDeviceOnly", - table: "SecurityPolicies"); - - migrationBuilder.DropColumn( - name: "SecondFactorExpiryMinutes", - table: "SecurityPolicies"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs b/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs deleted file mode 100644 index 501263d..0000000 --- a/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs +++ /dev/null @@ -1,269 +0,0 @@ -// -using System; -using Hua.Todo.Application.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260413140753_AddAuditLogs")] - partial class AddAuditLogs - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("ClientIp") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IsSuccess") - .HasColumnType("INTEGER"); - - b.Property("TimestampUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UserAgent") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("UserName") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("TimestampUtc"); - - b.HasIndex("UserId"); - - b.ToTable("AuditLogs", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AllowPersist") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("AllowSync") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("IsTrustedDeviceOnly") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("SecondFactorExpiryMinutes") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("SecurityPolicies", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.HasIndex("UserId"); - - b.ToTable("Tasks", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("StepUpExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany("Tasks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentTask"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Navigation("Tasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs b/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs deleted file mode 100644 index 5aa5f95..0000000 --- a/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class AddAuditLogs : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "SecondFactorExpiryMinutes", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - defaultValue: 30, - oldClrType: typeof(int), - oldType: "INTEGER"); - - migrationBuilder.AlterColumn( - name: "IsTrustedDeviceOnly", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - defaultValue: false, - oldClrType: typeof(bool), - oldType: "INTEGER"); - - migrationBuilder.CreateTable( - name: "AuditLogs", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - TimestampUtc = table.Column(type: "TEXT", nullable: false), - UserId = table.Column(type: "TEXT", nullable: true), - UserName = table.Column(type: "TEXT", nullable: true), - EventType = table.Column(type: "TEXT", maxLength: 64, nullable: false), - Description = table.Column(type: "TEXT", maxLength: 500, nullable: false), - ClientIp = table.Column(type: "TEXT", maxLength: 64, nullable: true), - UserAgent = table.Column(type: "TEXT", maxLength: 500, nullable: true), - IsSuccess = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AuditLogs", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_AuditLogs_TimestampUtc", - table: "AuditLogs", - column: "TimestampUtc"); - - migrationBuilder.CreateIndex( - name: "IX_AuditLogs_UserId", - table: "AuditLogs", - column: "UserId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AuditLogs"); - - migrationBuilder.AlterColumn( - name: "SecondFactorExpiryMinutes", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - oldClrType: typeof(int), - oldType: "INTEGER", - oldDefaultValue: 30); - - migrationBuilder.AlterColumn( - name: "IsTrustedDeviceOnly", - table: "SecurityPolicies", - type: "INTEGER", - nullable: false, - oldClrType: typeof(bool), - oldType: "INTEGER", - oldDefaultValue: false); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs b/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs deleted file mode 100644 index a825b6a..0000000 --- a/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs +++ /dev/null @@ -1,273 +0,0 @@ -// -using System; -using Hua.Todo.Application.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20260424164713_AddPasswordSaltToUsers")] - partial class AddPasswordSaltToUsers - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("ClientIp") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IsSuccess") - .HasColumnType("INTEGER"); - - b.Property("TimestampUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UserAgent") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("UserName") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("TimestampUtc"); - - b.HasIndex("UserId"); - - b.ToTable("AuditLogs", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AllowPersist") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("AllowSync") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); - - b.Property("IsTrustedDeviceOnly") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("SecondFactorExpiryMinutes") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("SecurityPolicies", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.HasIndex("UserId"); - - b.ToTable("Tasks", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("PasswordSalt") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("UpdatedAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserName") - .IsUnique(); - - b.ToTable("Users", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAtUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("StepUpExpiresAtUtc") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions", (string)null); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany("Tasks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentTask"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => - { - b.Navigation("Tasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs b/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs deleted file mode 100644 index be004a5..0000000 --- a/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class AddPasswordSaltToUsers : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "PasswordSalt", - table: "Users", - type: "TEXT", - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "PasswordSalt", - table: "Users"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs b/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs deleted file mode 100644 index 6415488..0000000 --- a/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - public partial class AddMustChangePasswordToUsers : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "MustChangePassword", - table: "Users", - type: "INTEGER", - nullable: false, - defaultValue: false); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "MustChangePassword", - table: "Users"); - } - } -} diff --git a/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs b/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs deleted file mode 100644 index c98f946..0000000 --- a/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - /// - /// - /// 重构 TaskEntity 继承 ABP 风格审计基类。 - /// - 主键从 int 改为 Guid - /// - ParentTaskId 从 int? 改为 Guid? - /// - 新增 ABP 审计字段:ExtraProperties, ConcurrencyStamp, CreationTime, CreatorId, - /// LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId - /// - 重命名 CreatedAt -> CreationTime, UpdatedAt -> LastModificationTime - /// - 表名从 Tasks 改为 T_Tasks - /// - public partial class MakeTaskEntityAbpCompatible : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // 1. 创建临时表 T_Tasks(新结构) - migrationBuilder.CreateTable( - name: "T_Tasks", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - ExtraProperties = table.Column(type: "TEXT", nullable: true), - ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), - CreationTime = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), - CreatorId = table.Column(type: "TEXT", nullable: true), - LastModificationTime = table.Column(type: "TEXT", nullable: true), - LastModifierId = table.Column(type: "TEXT", nullable: true), - IsDeleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - DeletionTime = table.Column(type: "TEXT", nullable: true), - DeleterId = table.Column(type: "TEXT", nullable: true), - UserId = table.Column(type: "TEXT", nullable: false, defaultValue: "local"), - Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), - IsCompleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - Code = table.Column(type: "TEXT", nullable: false, defaultValue: ""), - ParentTaskId = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_T_Tasks", x => x.Id); - table.ForeignKey( - name: "FK_T_Tasks_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_T_Tasks_T_Tasks_ParentTaskId", - column: x => x.ParentTaskId, - principalTable: "T_Tasks", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - // 2. 创建索引 - migrationBuilder.CreateIndex( - name: "IX_T_Tasks_ParentTaskId", - table: "T_Tasks", - column: "ParentTaskId"); - - migrationBuilder.CreateIndex( - name: "IX_T_Tasks_UserId", - table: "T_Tasks", - column: "UserId"); - - // 3. 数据迁移(int Id -> Guid,CreatedAt -> CreationTime,UpdatedAt -> LastModificationTime) - // 注意:原有数据会丢失,因为 int 无法直接转换为 Guid - // 如需保留数据,需先导出再导入 - - // 4. 先禁用外键检查,再重命名旧表(避免外键约束失败) - migrationBuilder.Sql("PRAGMA foreign_keys = OFF;"); - migrationBuilder.RenameTable(name: "Tasks", newName: "Tasks_Old"); - migrationBuilder.Sql("PRAGMA foreign_keys = ON;"); - - // 注意:不重命名 T_Tasks 为 Tasks,因为 TodoDbContext 配置的表名是 T_Tasks - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // 此回滚为破坏性操作,数据会丢失 - // 重建旧结构 Tasks 表(仅用于回滚,实际数据不可恢复) - - // 1. 禁用外键检查 - migrationBuilder.Sql("PRAGMA foreign_keys = OFF;"); - - // 2. 删除新表 - migrationBuilder.DropTable(name: "T_Tasks"); - - // 3. 重建旧结构 Tasks 表 - migrationBuilder.CreateTable( - name: "Tasks", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false), - UserId = table.Column(type: "TEXT", nullable: false, defaultValue: "local"), - Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), - IsCompleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), - UpdatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), - ParentTaskId = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Tasks", x => x.Id); - }); - - // 4. 如果有重命名的旧表,也删除 - migrationBuilder.Sql("DROP TABLE IF EXISTS Tasks_Old;"); - - // 5. 重新启用外键检查 - migrationBuilder.Sql("PRAGMA foreign_keys = ON;"); - } - } -} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs deleted file mode 100644 index 4e6620e..0000000 --- a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs +++ /dev/null @@ -1,78 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Hua.Todo.Application.Data; - -#nullable disable - -namespace Hua.Todo.Application.Migrations -{ - [DbContext(typeof(TodoDbContext))] - partial class TodoDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.Property("IsCompleted") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - - b.HasKey("Id"); - - b.HasIndex("ParentTaskId"); - - b.ToTable("Tasks"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") - .WithMany("SubTasks") - .HasForeignKey("ParentTaskId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ParentTask"); - }); - - modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => - { - b.Navigation("SubTasks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Hua.Todo.Application/Models/AttachmentModels.cs b/src/Hua.Todo.Application/Models/AttachmentModels.cs new file mode 100644 index 0000000..d20be0c --- /dev/null +++ b/src/Hua.Todo.Application/Models/AttachmentModels.cs @@ -0,0 +1,109 @@ +using Hua.Todo.Core.Entities; +using System.Text.Json.Serialization; + +namespace Hua.Todo.Application.Models; + +/// +/// 附件返回 DTO。 +/// +public class AttachmentDto +{ + /// + /// 附件 ID。 + /// + public Guid Id { get; set; } + + /// + /// 所属待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 显示名称。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件路径或外部 URL。 + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// 文件大小(字节),外部链接为 0。 + /// + public long FileSize { get; set; } + + /// + /// MIME 类型。 + /// + public string ContentType { get; set; } = string.Empty; + + /// + /// 附件类型(0=本地文件, 1=外部链接)。 + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public AttachmentType AttachmentType { get; set; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } +} + +/// +/// 上传附件请求 DTO。 +/// +public class UploadAttachmentRequest +{ + /// + /// 所属待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 原始文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Base64 编码的文件内容。 + /// + public string Base64Content { get; set; } = string.Empty; + + /// + /// 文件 MIME 类型。 + /// + public string ContentType { get; set; } = string.Empty; +} + +/// +/// 添加外部链接请求 DTO。 +/// +public class AddLinkRequest +{ + /// + /// 所属待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 外部链接 URL,仅允许 http:// 或 https:// 协议。 + /// + public string Url { get; set; } = string.Empty; + + /// + /// 链接显示名称,不传则使用 URL 作为名称。 + /// + public string? FileName { get; set; } +} + +/// +/// 打开附件响应 DTO。 +/// +public class OpenAttachmentResponse +{ + /// + /// 是否成功打开。 + /// + public bool Opened { get; set; } +} diff --git a/src/Hua.Todo.Application/Models/TaskModels.cs b/src/Hua.Todo.Application/Models/TaskModels.cs index 0493a0b..6a9aecc 100644 --- a/src/Hua.Todo.Application/Models/TaskModels.cs +++ b/src/Hua.Todo.Application/Models/TaskModels.cs @@ -13,38 +13,61 @@ public class CreateTaskDto /// public string Title { get; set; } = string.Empty; - [JsonConverter(typeof(JsonStringEnumConverter))] /// /// 任务优先级。 /// + [JsonConverter(typeof(JsonStringEnumConverter))] public TaskPriority Priority { get; set; } = TaskPriority.Medium; /// /// 父任务 ID(用于创建子任务)。 /// - public int? ParentTaskId { get; set; } + public Guid? ParentTaskId { get; set; } + + /// + /// 待办项类型(0=Normal, 1=Meeting),默认 Normal。 + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TaskType TaskType { get; set; } = TaskType.Normal; + + /// + /// 多行描述文本,最大 5000 字符。 + /// + public string? Description { get; set; } } /// /// 更新任务请求 DTO。 +/// 未传递的字段保持原值不变,传递 null 表示清空。 /// public class UpdateTaskDto { /// /// 任务 ID。 /// - public int Id { get; set; } + public Guid Id { get; set; } /// - /// 新标题(可选)。 + /// 新标题(可选,不传则保持原值)。 /// public string? Title { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] /// - /// 新优先级(可选)。 + /// 新优先级(可选,不传则保持原值)。 /// + [JsonConverter(typeof(JsonStringEnumConverter))] public TaskPriority? Priority { get; set; } + + /// + /// 待办项类型(可选,不传则保持原值)。 + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TaskType? TaskType { get; set; } + + /// + /// 多行描述文本(可选,不传则保持原值,传 null 则清空)。 + /// + public string? Description { get; set; } } /// @@ -55,16 +78,22 @@ public class TaskDto /// /// 任务 ID。 /// - public int Id { get; set; } + public Guid Id { get; set; } + + /// + /// 任务编号(用户级自增字符串,用于展示)。 + /// + public string Code { get; set; } = string.Empty; + /// /// 任务标题。 /// public string Title { get; set; } = string.Empty; - [JsonConverter(typeof(JsonStringEnumConverter))] /// /// 任务优先级。 /// + [JsonConverter(typeof(JsonStringEnumConverter))] public TaskPriority Priority { get; set; } /// @@ -82,11 +111,42 @@ public class TaskDto /// /// 父任务 ID(可选)。 /// - public int? ParentTaskId { get; set; } + public Guid? ParentTaskId { get; set; } /// /// 子任务列表。 /// public List SubTasks { get; set; } = new(); + + /// + /// 待办项类型。 + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TaskType TaskType { get; set; } + + /// + /// 会议纪要/转写文字。 + /// + public string? MeetingNotes { get; set; } + + /// + /// 录音时长(秒)。 + /// + public double? AudioDuration { get; set; } + + /// + /// 多行描述文本。 + /// + public string? Description { get; set; } + + /// + /// 附件列表(详情视图使用)。 + /// + public List? Attachments { get; set; } + + /// + /// 附件数量(列表视图使用,不传完整附件列表)。 + /// + public int? AttachmentCount { get; set; } } /// diff --git a/src/Hua.Todo.Application/Repositories/AttachmentRepository.cs b/src/Hua.Todo.Application/Repositories/AttachmentRepository.cs new file mode 100644 index 0000000..cffc271 --- /dev/null +++ b/src/Hua.Todo.Application/Repositories/AttachmentRepository.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Hua.Todo.Core.Entities; +using Hua.Todo.Core.Interfaces; + +namespace Hua.Todo.Application.Repositories; + +/// +/// 附件仓储实现(EF Core)。 +/// +public class AttachmentRepository : IAttachmentRepository +{ + private readonly TodoDbContext _context; + + /// + /// 创建 。 + /// + /// 数据库上下文。 + public AttachmentRepository(TodoDbContext context) + { + _context = context; + } + + /// + public async Task> GetByTaskIdAsync(Guid taskId) + { + return await _context.Attachments + .Where(a => a.TaskId == taskId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + } + + /// + public async Task GetByIdAsync(Guid id) + { + return await _context.Attachments + .FirstOrDefaultAsync(a => a.Id == id); + } + + /// + public async Task AddAsync(AttachmentEntity attachment) + { + _context.Attachments.Add(attachment); + await _context.SaveChangesAsync(); + return attachment; + } + + /// + public async Task DeleteAsync(Guid id) + { + var attachment = await _context.Attachments.FindAsync(id); + if (attachment != null) + { + _context.Attachments.Remove(attachment); + await _context.SaveChangesAsync(); + } + } + + /// + public async Task GetCountByTaskIdAsync(Guid taskId) + { + return await _context.Attachments + .CountAsync(a => a.TaskId == taskId); + } +} diff --git a/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs b/src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.Designer.cs similarity index 77% rename from src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs rename to src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.Designer.cs index 30d149a..61dbc9f 100644 --- a/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs +++ b/src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.Designer.cs @@ -1,6 +1,6 @@ -// +// using System; -using Hua.Todo.Application.Data; +using Hua.Todo.Application.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Hua.Todo.Application.Migrations { [DbContext(typeof(TodoDbContext))] - [Migration("MakeTaskEntityAbpCompatible")] - partial class MakeTaskEntityAbpCompatible + [Migration("20260616203619_InitialCreate")] + partial class InitialCreate { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -20,6 +20,50 @@ namespace Hua.Todo.Application.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Attachments", (string)null); + }); + modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => { b.Property("Id") @@ -43,8 +87,7 @@ namespace Hua.Todo.Application.Migrations b.Property("IsSuccess") .HasColumnType("INTEGER"); - b.Property("TimestampUtc") - .IsRequired() + b.Property("TimestampUtc") .HasColumnType("TEXT"); b.Property("UserAgent") @@ -83,9 +126,7 @@ namespace Hua.Todo.Application.Migrations .HasDefaultValue(true); b.Property("IsTrustedDeviceOnly") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); + .HasColumnType("INTEGER"); b.Property("SecondFactorExpiryMinutes") .ValueGeneratedOnAdd() @@ -109,6 +150,14 @@ namespace Hua.Todo.Application.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("AudioDuration") + .HasColumnType("REAL"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property("ConcurrencyStamp") .HasColumnType("TEXT"); @@ -120,10 +169,14 @@ namespace Hua.Todo.Application.Migrations b.Property("CreatorId") .HasColumnType("TEXT"); + b.Property("DeleterId") + .HasColumnType("TEXT"); + b.Property("DeletionTime") .HasColumnType("TEXT"); - b.Property("DeleterId") + b.Property("Description") + .HasMaxLength(5000) .HasColumnType("TEXT"); b.Property("ExtraProperties") @@ -134,10 +187,6 @@ namespace Hua.Todo.Application.Migrations .HasColumnType("INTEGER") .HasDefaultValue(false); - b.Property("Code") - .IsRequired() - .HasColumnType("TEXT"); - b.Property("IsDeleted") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -149,6 +198,10 @@ namespace Hua.Todo.Application.Migrations b.Property("LastModifierId") .HasColumnType("TEXT"); + b.Property("MeetingNotes") + .HasMaxLength(20000) + .HasColumnType("TEXT"); + b.Property("ParentTaskId") .HasColumnType("TEXT"); @@ -157,15 +210,18 @@ namespace Hua.Todo.Application.Migrations .HasColumnType("INTEGER") .HasDefaultValue(1); + b.Property("TaskType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("Title") .IsRequired() .HasMaxLength(200) .HasColumnType("TEXT"); b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + .HasColumnType("TEXT"); b.HasKey("Id"); @@ -223,15 +279,13 @@ namespace Hua.Todo.Application.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedAtUtc") - .IsRequired() + b.Property("CreatedAtUtc") .HasColumnType("TEXT"); - b.Property("ExpiresAtUtc") - .IsRequired() + b.Property("ExpiresAtUtc") .HasColumnType("TEXT"); - b.Property("StepUpExpiresAtUtc") + b.Property("StepUpExpiresAtUtc") .HasColumnType("TEXT"); b.Property("UserId") @@ -244,6 +298,17 @@ namespace Hua.Todo.Application.Migrations b.ToTable("UserSessions", (string)null); }); + modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "Task") + .WithMany("Attachments") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => { b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") @@ -286,6 +351,8 @@ namespace Hua.Todo.Application.Migrations modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => { + b.Navigation("Attachments"); + b.Navigation("SubTasks"); }); @@ -296,4 +363,4 @@ namespace Hua.Todo.Application.Migrations #pragma warning restore 612, 618 } } -} \ No newline at end of file +} diff --git a/src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.cs b/src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.cs new file mode 100644 index 0000000..4c3690c --- /dev/null +++ b/src/Hua.Todo.Application/Repositories/Migrations/20260616203619_InitialCreate.cs @@ -0,0 +1,225 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TimestampUtc = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: true), + UserName = table.Column(type: "TEXT", nullable: true), + EventType = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: false), + ClientIp = table.Column(type: "TEXT", maxLength: 64, nullable: true), + UserAgent = table.Column(type: "TEXT", maxLength: 500, nullable: true), + IsSuccess = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 64, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + PasswordSalt = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", maxLength: 32, nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false), + MustChangePassword = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SecurityPolicies", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + AllowPersist = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + AllowSync = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + SecondFactorExpiryMinutes = table.Column(type: "INTEGER", nullable: false, defaultValue: 30), + IsTrustedDeviceOnly = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecurityPolicies", x => x.Id); + table.ForeignKey( + name: "FK_SecurityPolicies_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "T_Tasks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), + Code = table.Column(type: "TEXT", maxLength: 32, nullable: false), + IsCompleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + ParentTaskId = table.Column(type: "TEXT", nullable: true), + TaskType = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + MeetingNotes = table.Column(type: "TEXT", maxLength: 20000, nullable: true), + AudioDuration = table.Column(type: "REAL", nullable: true), + Description = table.Column(type: "TEXT", maxLength: 5000, nullable: true), + ExtraProperties = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + CreationTime = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), + CreatorId = table.Column(type: "TEXT", nullable: true), + LastModificationTime = table.Column(type: "TEXT", nullable: true), + LastModifierId = table.Column(type: "TEXT", nullable: true), + IsDeleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + DeletionTime = table.Column(type: "TEXT", nullable: true), + DeleterId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_T_Tasks", x => x.Id); + table.ForeignKey( + name: "FK_T_Tasks_T_Tasks_ParentTaskId", + column: x => x.ParentTaskId, + principalTable: "T_Tasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_T_Tasks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), + StepUpExpiresAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Attachments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TaskId = table.Column(type: "TEXT", nullable: false), + FileName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + FilePath = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + FileSize = table.Column(type: "INTEGER", nullable: false), + ContentType = table.Column(type: "TEXT", maxLength: 128, nullable: false), + AttachmentType = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')") + }, + constraints: table => + { + table.PrimaryKey("PK_Attachments", x => x.Id); + table.ForeignKey( + name: "FK_Attachments_T_Tasks_TaskId", + column: x => x.TaskId, + principalTable: "T_Tasks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Attachments_TaskId", + table: "Attachments", + column: "TaskId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_TimestampUtc", + table: "AuditLogs", + column: "TimestampUtc"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_UserId", + table: "AuditLogs", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_SecurityPolicies_UserId", + table: "SecurityPolicies", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_T_Tasks_ParentTaskId", + table: "T_Tasks", + column: "ParentTaskId"); + + migrationBuilder.CreateIndex( + name: "IX_T_Tasks_UserId", + table: "T_Tasks", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_UserName", + table: "Users", + column: "UserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + table: "UserSessions", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Attachments"); + + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropTable( + name: "SecurityPolicies"); + + migrationBuilder.DropTable( + name: "UserSessions"); + + migrationBuilder.DropTable( + name: "T_Tasks"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs b/src/Hua.Todo.Application/Repositories/Migrations/TodoDbContextModelSnapshot.cs similarity index 68% rename from src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs rename to src/Hua.Todo.Application/Repositories/Migrations/TodoDbContextModelSnapshot.cs index 7c70e20..5c938e7 100644 --- a/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs +++ b/src/Hua.Todo.Application/Repositories/Migrations/TodoDbContextModelSnapshot.cs @@ -1,9 +1,8 @@ // using System; -using Hua.Todo.Application.Data; +using Hua.Todo.Application.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable @@ -11,15 +10,57 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Hua.Todo.Application.Migrations { [DbContext(typeof(TodoDbContext))] - [Migration("20260510171230_AddMustChangePasswordToUsers")] - partial class AddMustChangePasswordToUsers + partial class TodoDbContextModelSnapshot : ModelSnapshot { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) + protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Attachments", (string)null); + }); + modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => { b.Property("Id") @@ -43,8 +84,7 @@ namespace Hua.Todo.Application.Migrations b.Property("IsSuccess") .HasColumnType("INTEGER"); - b.Property("TimestampUtc") - .IsRequired() + b.Property("TimestampUtc") .HasColumnType("TEXT"); b.Property("UserAgent") @@ -83,9 +123,7 @@ namespace Hua.Todo.Application.Migrations .HasDefaultValue(true); b.Property("IsTrustedDeviceOnly") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); + .HasColumnType("INTEGER"); b.Property("SecondFactorExpiryMinutes") .ValueGeneratedOnAdd() @@ -105,44 +143,82 @@ namespace Hua.Todo.Application.Migrations modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("TEXT"); - b.Property("CreatedAt") + b.Property("AudioDuration") + .HasColumnType("REAL"); + + b.Property("Code") .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("CreationTime") .ValueGeneratedOnAdd() .HasColumnType("TEXT") .HasDefaultValueSql("datetime('now')"); + b.Property("CreatorId") + .HasColumnType("TEXT"); + + b.Property("DeleterId") + .HasColumnType("TEXT"); + + b.Property("DeletionTime") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("ExtraProperties") + .HasColumnType("TEXT"); + b.Property("IsCompleted") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(false); - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastModificationTime") + .HasColumnType("TEXT"); + + b.Property("LastModifierId") + .HasColumnType("TEXT"); + + b.Property("MeetingNotes") + .HasMaxLength(20000) + .HasColumnType("TEXT"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT"); b.Property("Priority") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(1); + b.Property("TaskType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("Title") .IsRequired() .HasMaxLength(200) .HasColumnType("TEXT"); - b.Property("UpdatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + .HasColumnType("TEXT"); b.HasKey("Id"); @@ -150,7 +226,7 @@ namespace Hua.Todo.Application.Migrations b.HasIndex("UserId"); - b.ToTable("Tasks", (string)null); + b.ToTable("T_Tasks", (string)null); }); modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => @@ -200,15 +276,13 @@ namespace Hua.Todo.Application.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedAtUtc") - .IsRequired() + b.Property("CreatedAtUtc") .HasColumnType("TEXT"); - b.Property("ExpiresAtUtc") - .IsRequired() + b.Property("ExpiresAtUtc") .HasColumnType("TEXT"); - b.Property("StepUpExpiresAtUtc") + b.Property("StepUpExpiresAtUtc") .HasColumnType("TEXT"); b.Property("UserId") @@ -221,6 +295,17 @@ namespace Hua.Todo.Application.Migrations b.ToTable("UserSessions", (string)null); }); + modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "Task") + .WithMany("Attachments") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => { b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") @@ -263,6 +348,8 @@ namespace Hua.Todo.Application.Migrations modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => { + b.Navigation("Attachments"); + b.Navigation("SubTasks"); }); diff --git a/src/Hua.Todo.Application/Repositories/TaskRepository.cs b/src/Hua.Todo.Application/Repositories/TaskRepository.cs index b352d99..627bedf 100644 --- a/src/Hua.Todo.Application/Repositories/TaskRepository.cs +++ b/src/Hua.Todo.Application/Repositories/TaskRepository.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Hua.Todo.Application.Data; using Hua.Todo.Core.Entities; using Hua.Todo.Core.Interfaces; @@ -28,7 +27,7 @@ public class TaskRepository : ITaskRepository public async Task> GetAllAsync() { return await _context.Tasks - .Include(t => t.SubTasks) + .Include(t => t.SubTasks!) .ToListAsync(); } @@ -37,10 +36,10 @@ public class TaskRepository : ITaskRepository /// /// 任务 ID。 /// 匹配的任务实体;如果不存在则返回 null。 - public async Task GetByIdAsync(int id) + public async Task GetByIdAsync(Guid id) { return await _context.Tasks - .Include(t => t.SubTasks) + .Include(t => t.SubTasks!) .FirstOrDefaultAsync(t => t.Id == id); } @@ -52,7 +51,7 @@ public class TaskRepository : ITaskRepository { return await _context.Tasks .Where(t => !t.IsCompleted) - .OrderByDescending(t => t.CreatedAt) + .OrderByDescending(t => t.CreationTime) .ToListAsync(); } @@ -64,7 +63,7 @@ public class TaskRepository : ITaskRepository { return await _context.Tasks .Where(t => t.IsCompleted) - .OrderByDescending(t => t.UpdatedAt) + .OrderByDescending(t => t.LastModificationTime) .ToListAsync(); } @@ -87,7 +86,7 @@ public class TaskRepository : ITaskRepository /// 更新后的任务实体。 public async Task UpdateAsync(TaskEntity taskEntity) { - taskEntity.UpdatedAt = DateTime.UtcNow; + taskEntity.LastModificationTime = DateTime.UtcNow; _context.Tasks.Update(taskEntity); await _context.SaveChangesAsync(); return taskEntity; @@ -98,7 +97,7 @@ public class TaskRepository : ITaskRepository /// /// 要删除的任务 ID。 /// 表示删除操作的任务。 - public async Task DeleteAsync(int id) + public async Task DeleteAsync(Guid id) { var task = await _context.Tasks.FindAsync(id); if (task != null) @@ -113,11 +112,35 @@ public class TaskRepository : ITaskRepository /// /// 父任务 ID。 /// 子任务实体的列表。 - public async Task> GetSubTasksAsync(int parentTaskId) + public async Task> GetSubTasksAsync(Guid parentTaskId) { return await _context.Tasks .Where(t => t.ParentTaskId == parentTaskId) - .OrderByDescending(t => t.CreatedAt) + .OrderByDescending(t => t.CreationTime) .ToListAsync(); } + + /// + /// 获取指定用户的任务中最大的数字型 Code。 + /// + /// 用户 ID。 + /// 最大数字 Code;若无任务或无可解析 Code 则返回 0。 + public async Task GetMaxCodeAsync(Guid userId) + { + var codes = await _context.Tasks + .AsNoTracking() + .Where(t => t.UserId == userId) + .Select(t => t.Code) + .ToListAsync(); + + int max = 0; + foreach (var code in codes) + { + if (int.TryParse(code, out var value) && value > max) + { + max = value; + } + } + return max; + } } diff --git a/src/Hua.Todo.Application/Repositories/TodoDbContext.cs b/src/Hua.Todo.Application/Repositories/TodoDbContext.cs new file mode 100644 index 0000000..4b6cce7 --- /dev/null +++ b/src/Hua.Todo.Application/Repositories/TodoDbContext.cs @@ -0,0 +1,187 @@ +using Hua.Todo.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Hua.Todo.Application.Repositories; + +/// +/// 应用程序数据库上下文(EF Core)。 +/// +public class TodoDbContext : DbContext +{ + /// + /// 创建 。 + /// + /// 数据库上下文配置。 + public TodoDbContext(DbContextOptions options) : base(options) + { + } + + /// + /// 配置 DbContext 行为,抑制开发期间模型变化导致的迁移验证警告。 + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + } + + /// + /// 任务集合。 + /// + public DbSet Tasks { get; set; } + + /// + /// 附件集合。 + /// + public DbSet Attachments { get; set; } + + /// + /// 用户集合(云同步)。 + /// + public DbSet Users { get; set; } + + /// + /// 用户会话集合(云同步)。 + /// + public DbSet UserSessions { get; set; } + + /// + /// 安全策略集合(云同步)。 + /// + public DbSet SecurityPolicies { get; set; } + + /// + /// 审计日志集合(云同步)。 + /// + public DbSet AuditLogs { get; set; } + + /// + /// 配置实体模型映射。 + /// + /// 模型构建器。 + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.ToTable("T_Tasks"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Title).IsRequired().HasMaxLength(200); + entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium).HasSentinel(TaskPriority.Low); + entity.Property(e => e.IsCompleted).HasDefaultValue(false); + + // ABP 审计字段 + entity.Property(e => e.CreationTime).HasDefaultValueSql("datetime('now')"); + entity.Property(e => e.LastModificationTime).IsRequired(false); + entity.Property(e => e.CreatorId).IsRequired(false); + entity.Property(e => e.LastModifierId).IsRequired(false); + entity.Property(e => e.IsDeleted).HasDefaultValue(false); + entity.Property(e => e.DeletionTime).IsRequired(false); + entity.Property(e => e.DeleterId).IsRequired(false); + entity.Property(e => e.ConcurrencyStamp).IsRequired(false); + + // 用户隔离字段 + entity.Property(e => e.UserId).IsRequired(); + entity.Property(e => e.Code).HasMaxLength(32); + + entity.Property(e => e.TaskType) + .HasDefaultValue(TaskType.Normal) + .HasConversion(); + + entity.Property(e => e.MeetingNotes) + .HasMaxLength(20000); + + entity.Property(e => e.AudioDuration) + .IsRequired(false); + + entity.Property(e => e.Description) + .HasMaxLength(5000); + + entity.HasIndex(e => e.UserId); + + entity.HasOne(e => e.ParentTask) + .WithMany(e => e.SubTasks) + .HasForeignKey(e => e.ParentTaskId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(e => e.User) + .WithMany(e => e.Tasks) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Attachments"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + + entity.Property(e => e.FileName) + .IsRequired() + .HasMaxLength(256); + + entity.Property(e => e.FilePath) + .IsRequired() + .HasMaxLength(1024); + + entity.Property(e => e.ContentType) + .HasMaxLength(128); + + entity.Property(e => e.AttachmentType) + .HasConversion() + .HasDefaultValue(AttachmentType.LocalFile); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("datetime('now')"); + + entity.HasOne(e => e.Task) + .WithMany(e => e.Attachments) + .HasForeignKey(e => e.TaskId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.UserName).IsUnique(); + entity.Property(e => e.UserName).IsRequired().HasMaxLength(64); + entity.Property(e => e.PasswordHash).IsRequired(); + entity.Property(e => e.PasswordSalt).IsRequired(); + entity.Property(e => e.Role).HasMaxLength(32); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserSessions"); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.UserId); + entity.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("SecurityPolicies"); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.UserId).IsUnique(); + entity.Property(e => e.AllowPersist).HasDefaultValue(true); + entity.Property(e => e.AllowSync).HasDefaultValue(true); + entity.Property(e => e.SecondFactorExpiryMinutes).HasDefaultValue(30); + entity.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("AuditLogs"); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TimestampUtc); + entity.HasIndex(e => e.UserId); + entity.Property(e => e.EventType).HasMaxLength(64); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.ClientIp).HasMaxLength(64); + entity.Property(e => e.UserAgent).HasMaxLength(500); + }); + } +} diff --git a/src/Hua.Todo.Application/Services/AttachmentService.cs b/src/Hua.Todo.Application/Services/AttachmentService.cs new file mode 100644 index 0000000..288fb43 --- /dev/null +++ b/src/Hua.Todo.Application/Services/AttachmentService.cs @@ -0,0 +1,274 @@ +using Hua.Todo.Application.Models; +using Hua.Todo.Core.Entities; +using Hua.Todo.Core.Interfaces; +using Hua.Todo.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Hua.Todo.Application.Services; + +/// +/// 附件管理服务实现。 +/// +public class AttachmentService : IAttachmentService +{ + private readonly IAttachmentRepository _attachmentRepository; + private readonly ITaskRepository _taskRepository; + private readonly IPlatformAttachmentOpener? _platformOpener; + private readonly ILogger _logger; + + private const long MaxFileSize = 50 * 1024 * 1024; // 50MB + private const int MaxAttachmentsPerTask = 20; + + /// + /// 获取附件存储根目录(与数据库同级)。 + /// + private static string AttachmentsRootPath => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Hua.Todo", + "Attachments"); + + /// + /// 初始化附件服务。 + /// + /// 附件仓储。 + /// 任务仓储。 + /// 平台文件打开服务(可选,MAUI/Avalonia 注入)。 + /// 日志记录器;未注入时使用空日志实现。 + public AttachmentService( + IAttachmentRepository attachmentRepository, + ITaskRepository taskRepository, + IPlatformAttachmentOpener? platformOpener = null, + ILogger? logger = null) + { + _attachmentRepository = attachmentRepository; + _taskRepository = taskRepository; + _platformOpener = platformOpener; + _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + /// + public async Task UploadAsync(UploadAttachmentRequest request) + { + if (string.IsNullOrEmpty(request.FileName)) + throw new ArgumentException("文件名不能为空"); + if (string.IsNullOrEmpty(request.Base64Content)) + throw new ArgumentException("文件内容不能为空"); + + // 验证待办项存在 + var task = await _taskRepository.GetByIdAsync(request.TaskId); + if (task == null) + throw new KeyNotFoundException($"待办项 {request.TaskId} 不存在"); + + // 检查附件数量限制 + var currentCount = await _attachmentRepository.GetCountByTaskIdAsync(request.TaskId); + if (currentCount >= MaxAttachmentsPerTask) + throw new InvalidOperationException($"每个待办项最多 {MaxAttachmentsPerTask} 个附件"); + + // 解码 Base64 + byte[] fileBytes; + try + { + fileBytes = Convert.FromBase64String(request.Base64Content); + } + catch (FormatException) + { + throw new ArgumentException("文件内容 Base64 编码无效"); + } + + if (fileBytes.Length == 0) + throw new ArgumentException("文件内容为空"); + + if (fileBytes.Length > MaxFileSize) + throw new ArgumentException($"文件大小不能超过 {MaxFileSize / (1024 * 1024)}MB"); + + // 清理文件名中的危险路径片段 + var safeFileName = SanitizeFileName(request.FileName); + + // 先保存实体获取 ID + var attachment = new AttachmentEntity + { + Id = Guid.NewGuid(), + TaskId = request.TaskId, + FileName = safeFileName, + FilePath = string.Empty, // 待定,拿到 ID 后填充 + FileSize = fileBytes.Length, + ContentType = string.IsNullOrEmpty(request.ContentType) ? "application/octet-stream" : request.ContentType, + AttachmentType = AttachmentType.LocalFile, + CreatedAt = DateTime.UtcNow + }; + + var created = await _attachmentRepository.AddAsync(attachment); + + // 用 ID 构造文件路径 + var storageDir = AttachmentsRootPath; + Directory.CreateDirectory(storageDir); + var storagePath = Path.Combine(storageDir, $"{created.Id}_{safeFileName}"); + await File.WriteAllBytesAsync(storagePath, fileBytes); + + // 更新 FilePath + created.FilePath = storagePath; + await _attachmentRepository.AddAsync(created); // 再保存一次以更新路径字段 + + return MapToDto(created); + } + + /// + public async Task AddLinkAsync(AddLinkRequest request) + { + if (string.IsNullOrEmpty(request.Url)) + throw new ArgumentException("URL 不能为空"); + + var uri = new Uri(request.Url); + if (uri.Scheme != "http" && uri.Scheme != "https") + throw new ArgumentException("仅支持 http:// 或 https:// 协议的链接"); + + // 验证待办项存在 + var task = await _taskRepository.GetByIdAsync(request.TaskId); + if (task == null) + throw new KeyNotFoundException($"待办项 {request.TaskId} 不存在"); + + // 检查附件数量限制 + var currentCount = await _attachmentRepository.GetCountByTaskIdAsync(request.TaskId); + if (currentCount >= MaxAttachmentsPerTask) + throw new InvalidOperationException($"每个待办项最多 {MaxAttachmentsPerTask} 个附件"); + + var attachment = new AttachmentEntity + { + Id = Guid.NewGuid(), + TaskId = request.TaskId, + FileName = string.IsNullOrEmpty(request.FileName) ? request.Url : request.FileName, + FilePath = request.Url, + FileSize = 0, + ContentType = string.Empty, + AttachmentType = AttachmentType.ExternalLink, + CreatedAt = DateTime.UtcNow + }; + + var created = await _attachmentRepository.AddAsync(attachment); + return MapToDto(created); + } + + /// + public async Task> GetAttachmentsAsync(Guid taskId) + { + var attachments = await _attachmentRepository.GetByTaskIdAsync(taskId); + return attachments.Select(MapToDto).ToList(); + } + + /// + public async Task DeleteAsync(Guid id) + { + var attachment = await _attachmentRepository.GetByIdAsync(id); + if (attachment == null) + throw new KeyNotFoundException($"附件 {id} 不存在"); + + // 删除磁盘文件(仅 LocalFile 类型) + if (attachment.AttachmentType == AttachmentType.LocalFile && !string.IsNullOrEmpty(attachment.FilePath)) + { + try + { + if (File.Exists(attachment.FilePath)) + File.Delete(attachment.FilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "删除附件文件失败: {FilePath}", attachment.FilePath); + // 文件删除失败不阻断数据库删除,仅记录日志 + } + } + + await _attachmentRepository.DeleteAsync(id); + } + + /// + public async Task OpenAsync(Guid id) + { + var attachment = await _attachmentRepository.GetByIdAsync(id); + if (attachment == null) + throw new KeyNotFoundException($"附件 {id} 不存在"); + + string pathToOpen; + if (attachment.AttachmentType == AttachmentType.ExternalLink) + { + // 外部链接:验证 URL 有效性后打开 + var uri = new Uri(attachment.FilePath); + if (uri.Scheme != "http" && uri.Scheme != "https") + throw new InvalidOperationException("外部链接仅支持 http:// 或 https:// 协议"); + pathToOpen = attachment.FilePath; + } + else + { + if (string.IsNullOrEmpty(attachment.FilePath) || !File.Exists(attachment.FilePath)) + throw new FileNotFoundException($"附件文件不存在: {attachment.FileName}"); + pathToOpen = attachment.FilePath; + } + + if (_platformOpener != null) + { + await _platformOpener.OpenAsync(pathToOpen); + } + else + { + // 回退:直接使用 Process.Start + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = pathToOpen, + UseShellExecute = true + }); + } + + return new OpenAttachmentResponse { Opened = true }; + } + + /// + /// 清理文件名中的危险路径片段,防止路径遍历攻击。 + /// + private static string SanitizeFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + return "unnamed"; + + // 去除路径分隔符和危险字符 + var sanitized = fileName + .Replace("..\\", "") + .Replace("../", "") + .Replace("..", "") + .Replace("/", "_") + .Replace("\\", "_") + .Replace(":", "_") + .Replace("*", "_") + .Replace("?", "_") + .Replace("\"", "_") + .Replace("<", "_") + .Replace(">", "_") + .Replace("|", "_") + .Replace("\0", ""); + + if (string.IsNullOrWhiteSpace(sanitized)) + return "unnamed"; + + if (sanitized.Length > 240) // 留空间给 ID 前缀 + sanitized = sanitized[..240]; + + return sanitized; + } + + /// + /// 实体转 DTO 映射。 + /// + private static AttachmentDto MapToDto(AttachmentEntity attachment) + { + return new AttachmentDto + { + Id = attachment.Id, + TaskId = attachment.TaskId, + FileName = attachment.FileName, + FilePath = attachment.AttachmentType == AttachmentType.LocalFile ? string.Empty : attachment.FilePath, + FileSize = attachment.FileSize, + ContentType = attachment.ContentType, + AttachmentType = attachment.AttachmentType, + CreatedAt = attachment.CreatedAt + }; + } +} diff --git a/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs b/src/Hua.Todo.Application/Services/CloudSync/Auth/ClaimsPrincipalExtensions.cs similarity index 87% rename from src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs rename to src/Hua.Todo.Application/Services/CloudSync/Auth/ClaimsPrincipalExtensions.cs index 6390217..acc69c6 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Auth/ClaimsPrincipalExtensions.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.Application.Services.CloudSync.Auth; /// /// 云同步鉴权相关的 扩展方法。 @@ -14,7 +14,7 @@ public static class ClaimsPrincipalExtensions /// 用户 ID。 public static Guid? GetUserId(this ClaimsPrincipal user) { - var value = user.FindFirstValue(ClaimTypes.NameIdentifier); + var value = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return Guid.TryParse(value, out var id) ? id : null; } @@ -25,7 +25,7 @@ public static class ClaimsPrincipalExtensions /// 会话 ID。 public static Guid? GetSessionId(this ClaimsPrincipal user) { - var value = user.FindFirstValue(ClaimTypes.Sid); + var value = user.FindFirst(ClaimTypes.Sid)?.Value; return Guid.TryParse(value, out var id) ? id : null; } diff --git a/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs b/src/Hua.Todo.Application/Services/CloudSync/Auth/CloudClaims.cs similarity index 81% rename from src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs rename to src/Hua.Todo.Application/Services/CloudSync/Auth/CloudClaims.cs index b3af006..6b24192 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Auth/CloudClaims.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.Application.Services.CloudSync.Auth; /// /// 云同步鉴权使用的 Claim 名称约定。 diff --git a/src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs b/src/Hua.Todo.Application/Services/CloudSync/Auth/CloudPermissions.cs similarity index 94% rename from src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs rename to src/Hua.Todo.Application/Services/CloudSync/Auth/CloudPermissions.cs index 7702fd9..59236fc 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Auth/CloudPermissions.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.Application.Services.CloudSync.Auth; /// /// 云同步权限点常量。 diff --git a/src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs b/src/Hua.Todo.Application/Services/CloudSync/Auth/DefaultRolePermissionMapper.cs similarity index 95% rename from src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs rename to src/Hua.Todo.Application/Services/CloudSync/Auth/DefaultRolePermissionMapper.cs index 1166c8a..18e9703 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Auth/DefaultRolePermissionMapper.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.Application.Services.CloudSync.Auth; /// /// 默认的角色-权限映射(内置最小集合)。 diff --git a/src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs b/src/Hua.Todo.Application/Services/CloudSync/Auth/IRolePermissionMapper.cs similarity index 87% rename from src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs rename to src/Hua.Todo.Application/Services/CloudSync/Auth/IRolePermissionMapper.cs index 03779c2..6f16a4c 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Auth/IRolePermissionMapper.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.Application.Services.CloudSync.Auth; /// /// 角色到权限集合的映射器(RBAC 最小落地)。 diff --git a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs b/src/Hua.Todo.Application/Services/CloudSync/Auth/SessionAuthenticationDefaults.cs similarity index 81% rename from src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs rename to src/Hua.Todo.Application/Services/CloudSync/Auth/SessionAuthenticationDefaults.cs index 1f57c2e..9f0ac87 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Auth/SessionAuthenticationDefaults.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.Application.Services.CloudSync.Auth; /// /// 云同步会话鉴权默认配置。 diff --git a/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs b/src/Hua.Todo.Application/Services/CloudSync/Models/AdminDtos.cs similarity index 87% rename from src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs rename to src/Hua.Todo.Application/Services/CloudSync/Models/AdminDtos.cs index fe3372b..63d0e0d 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Models/AdminDtos.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Models; +namespace Hua.Todo.Application.Services.CloudSync.Models; /// /// 用户信息 DTO(管理端使用)。 @@ -27,6 +27,14 @@ public class CreateUserRequest /// public class ResetPasswordRequest { + /// + /// 目标用户 ID。 + /// + public Guid UserId { get; set; } + + /// + /// 新密码。 + /// public string NewPassword { get; set; } = string.Empty; } diff --git a/src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs b/src/Hua.Todo.Application/Services/CloudSync/Models/ApiErrorResponse.cs similarity index 87% rename from src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs rename to src/Hua.Todo.Application/Services/CloudSync/Models/ApiErrorResponse.cs index c294067..3f1eee9 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Models/ApiErrorResponse.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Models; +namespace Hua.Todo.Application.Services.CloudSync.Models; /// /// 云同步 API 统一错误响应结构。 diff --git a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs b/src/Hua.Todo.Application/Services/CloudSync/Models/AuthDtos.cs similarity index 97% rename from src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs rename to src/Hua.Todo.Application/Services/CloudSync/Models/AuthDtos.cs index f523875..bde458c 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Models/AuthDtos.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Models; +namespace Hua.Todo.Application.Services.CloudSync.Models; /// /// 登录请求。 diff --git a/src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs b/src/Hua.Todo.Application/Services/CloudSync/Models/ProbeDtos.cs similarity index 94% rename from src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs rename to src/Hua.Todo.Application/Services/CloudSync/Models/ProbeDtos.cs index a38e1c4..a368cb9 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Models/ProbeDtos.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Models; +namespace Hua.Todo.Application.Services.CloudSync.Models; /// /// 服务端探测请求。 diff --git a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs b/src/Hua.Todo.Application/Services/CloudSync/Models/SecurityPolicyDtos.cs similarity index 93% rename from src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs rename to src/Hua.Todo.Application/Services/CloudSync/Models/SecurityPolicyDtos.cs index 4ccb687..9313d9e 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Models/SecurityPolicyDtos.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.CloudSync.Models; +namespace Hua.Todo.Application.Services.CloudSync.Models; /// /// 安全策略下发 DTO。 diff --git a/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs b/src/Hua.Todo.Application/Services/CloudSync/Models/TaskSyncDtos.cs similarity index 98% rename from src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs rename to src/Hua.Todo.Application/Services/CloudSync/Models/TaskSyncDtos.cs index 83142d0..126583d 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Models/TaskSyncDtos.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Hua.Todo.Core.Entities; -namespace Hua.Todo.Application.CloudSync.Models; +namespace Hua.Todo.Application.Services.CloudSync.Models; /// /// 云同步任务条目(ABP 标准)。 diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudAdminService.cs similarity index 71% rename from src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs rename to src/Hua.Todo.Application/Services/CloudSync/Services/CloudAdminService.cs index c05ef12..bf1ba2a 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudAdminService.cs @@ -1,15 +1,15 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.Data; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Models; using Hua.Todo.Core.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; -namespace Hua.Todo.Application.CloudSync.Services; +namespace Hua.Todo.Application.Services.CloudSync.Services; /// /// 管理端服务(账户、会话、审计、全局策略管理)。 /// -public class CloudAdminService +public class CloudAdminService : ICloudAdminService { private readonly TodoDbContext _dbContext; private readonly IPasswordHasher _passwordHasher; @@ -65,7 +65,18 @@ public class CloudAdminService return new UserDto { Id = user.Id, UserName = user.UserName, Role = user.Role, CreatedAtUtc = user.CreatedAtUtc, UpdatedAtUtc = user.UpdatedAtUtc }; } - public async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) + public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.NewPassword)) + return false; + + return await ResetPasswordAsync(request.UserId, request.NewPassword, cancellationToken); + } + + /// + /// 重置用户密码(内部方法)。 + /// + private async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) { var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (user == null) return false; @@ -122,12 +133,13 @@ public class CloudAdminService #region Audit Logs - public async Task> GetAuditLogsAsync(int count, CancellationToken cancellationToken) + public async Task> GetAuditLogsAsync(int? count = 100, CancellationToken cancellationToken = default) { + var take = count ?? 100; return await _dbContext.AuditLogs .AsNoTracking() .OrderByDescending(l => l.TimestampUtc) - .Take(count) + .Take(take) .Select(l => new AuditLogDto { Id = l.Id, @@ -144,4 +156,16 @@ public class CloudAdminService } #endregion -} + + #region ICloudAdminService 接口方法(无 CancellationToken,供 DynamicApi 调用) + + Task> ICloudAdminService.GetUsersAsync() => GetUsersAsync(CancellationToken.None); + Task ICloudAdminService.CreateUserAsync(CreateUserRequest request) => CreateUserAsync(request, CancellationToken.None); + Task ICloudAdminService.ResetPasswordAsync(ResetPasswordRequest request) => ResetPasswordAsync(request, CancellationToken.None); + Task ICloudAdminService.DeleteUserAsync(Guid userId) => DeleteUserAsync(userId, CancellationToken.None); + Task> ICloudAdminService.GetSessionsAsync() => GetSessionsAsync(CancellationToken.None); + Task ICloudAdminService.RevokeSessionAsync(Guid sessionId) => RevokeSessionAsync(sessionId, CancellationToken.None); + Task> ICloudAdminService.GetAuditLogsAsync(int? count) => GetAuditLogsAsync(count, CancellationToken.None); + + #endregion +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudAuthService.cs similarity index 80% rename from src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs rename to src/Hua.Todo.Application/Services/CloudSync/Services/CloudAuthService.cs index 69c85d2..86db9ba 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudAuthService.cs @@ -1,20 +1,22 @@ +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Auth; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.Application.Services.Interfaces; +using Hua.Todo.Core.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Hua.Todo.Application.CloudSync.Auth; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.Data; -using Hua.Todo.Core.Entities; -namespace Hua.Todo.Application.CloudSync.Services; +namespace Hua.Todo.Application.Services.CloudSync.Services; /// /// 云同步认证服务(登录、初始化管理员、二次认证)。 /// -public class CloudAuthService +public class CloudAuthService : ICloudAuthService { private readonly TodoDbContext _dbContext; private readonly IPasswordHasher _passwordHasher; private readonly IRolePermissionMapper _rolePermissionMapper; + private readonly ICurrentUserAccessor? _currentUserAccessor; private static readonly Random _random = new(); /// @@ -23,14 +25,17 @@ public class CloudAuthService /// 数据库上下文。 /// 密码哈希器。 /// 角色权限映射器。 + /// HTTP 上下文访问器(可选,用于获取客户端 IP/UA 等)。 public CloudAuthService( TodoDbContext dbContext, IPasswordHasher passwordHasher, - IRolePermissionMapper rolePermissionMapper) + IRolePermissionMapper rolePermissionMapper, + ICurrentUserAccessor? currentUserAccessor = null) { _dbContext = dbContext; _passwordHasher = passwordHasher; _rolePermissionMapper = rolePermissionMapper; + _currentUserAccessor = currentUserAccessor; } private static string GenerateRandomPassword(int length = 16) @@ -216,7 +221,6 @@ public class CloudAuthService }; } - /// /// /// 修改用户密码。 /// @@ -284,5 +288,57 @@ public class CloudAuthService await _dbContext.SaveChangesAsync(cancellationToken); return true; } -} + #region ICloudAuthService 接口方法(从 HttpContext 提取客户端信息) + + /// + /// 初始化系统管理员账号(接口方法,从 HttpContext 提取 IP/UA)。 + /// + public async Task BootstrapAsync(BootstrapAdminRequest request) + { + var (ip, ua) = GetClientInfo(); + return await BootstrapAdminAsync(request.UserName, request.Password, ip, ua, CancellationToken.None); + } + + /// + /// 用户名密码登录(接口方法,从 HttpContext 提取 IP/UA)。 + /// + public async Task LoginAsync(LoginRequest request) + { + var (ip, ua) = GetClientInfo(); + return await LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, CancellationToken.None); + } + + /// + /// 修改当前用户密码(接口方法,从 HttpContext 提取 sessionId + IP/UA)。 + /// + public Task ChangePasswordAsync(ChangePasswordRequest request) + { + var sessionId = _currentUserAccessor?.GetCurrentSessionId(); + if (sessionId == null) return Task.FromResult(false); + + var (ip, ua) = GetClientInfo(); + return ChangePasswordAsync(sessionId.Value, request.CurrentPassword, request.NewPassword, ip, ua, CancellationToken.None); + } + + /// + /// 登出(接口方法,从 HttpContext 提取 sessionId)。 + /// + public Task LogoutAsync() + { + var sessionId = _currentUserAccessor?.GetCurrentSessionId(); + if (sessionId == null) return Task.FromResult(false); + + return LogoutAsync(sessionId.Value, CancellationToken.None); + } + + /// + /// 从 ICurrentUserAccessor 获取客户端 IP 和 User-Agent。 + /// + private (string? ip, string? ua) GetClientInfo() + { + return (_currentUserAccessor?.GetClientIp(), _currentUserAccessor?.GetUserAgent()); + } + + #endregion +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudProbeService.cs similarity index 80% rename from src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs rename to src/Hua.Todo.Application/Services/CloudSync/Services/CloudProbeService.cs index d36d16d..2b70576 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudProbeService.cs @@ -1,32 +1,41 @@ -using System.Net; -using System.Net.Http; -using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.Services.CloudSync.Models; +using Microsoft.Extensions.Logging; -namespace Hua.Todo.Application.CloudSync.Services; +namespace Hua.Todo.Application.Services.CloudSync.Services; /// /// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。 /// -public class CloudProbeService +public class CloudProbeService : ICloudProbeService { private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; /// /// 创建 。 /// /// HTTP 客户端工厂。 - public CloudProbeService(IHttpClientFactory httpClientFactory) + /// 日志记录器。 + public CloudProbeService(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; + _logger = logger; } /// /// 探测目标 URL 的可达性。 /// - /// 目标 URL。 - /// 取消令牌。 + /// 探测请求(含 TargetUrl)。 /// 探测响应。 - public async Task ProbeAsync(string targetUrl, CancellationToken cancellationToken) + public Task ProbeAsync(ProbeRequest request) + { + return ProbeCoreAsync(request?.TargetUrl ?? string.Empty, CancellationToken.None); + } + + /// + /// 探测目标 URL 的可达性(内部实现)。 + /// + private async Task ProbeCoreAsync(string targetUrl, CancellationToken cancellationToken) { var response = new ProbeResponse(); @@ -91,7 +100,6 @@ public class CloudProbeService } else { - // 非 2xx 也说明服务器可达,仅作为提示 if (!response.IsHttps) { response.Type = "warn"; @@ -112,6 +120,7 @@ public class CloudProbeService response.Type = response.IsHttps ? "error" : "warn"; response.Title = "存在风险"; response.Description = "探测超时(网络不可达或目标无响应)。"; + _logger.LogWarning("探测超时: {TargetUrl}", targetUrl); } catch (HttpRequestException ex) { @@ -121,15 +130,16 @@ public class CloudProbeService response.Description = response.IsHttps ? $"探测失败:{ex.Message}" : $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。"; + _logger.LogWarning(ex, "探测请求失败: {TargetUrl}", targetUrl); } - catch (Exception) + catch (Exception ex) { response.IsReachable = false; response.Type = "error"; response.Description = "探测过程中发生未知错误。"; + _logger.LogError(ex, "探测未知错误: {TargetUrl}", targetUrl); } return response; } - -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs new file mode 100644 index 0000000..55c44a0 --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs @@ -0,0 +1,153 @@ +using System.Net.Http.Headers; +using Hua.Todo.Application.Services.CloudSync.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; + +/// +/// 云同步代理服务(单例)。 +/// 职责:管理代理 URL 状态 + 云同步请求转发到远程 Host + DI/管道配置。 +/// +public class CloudSyncProxyService +{ + private readonly IHttpClientFactory _httpClientFactory; + private string? _cloudSyncUrl; + private readonly object _lock = new(); + + /// + /// 需要代理的云同步路径根。匹配时要求路径等于根路径或以“根路径/”开头, + /// 避免 /api/tasks 无尾斜杠漏代理,也避免误匹配 /api/task 等本地 Todo API。 + /// + private static readonly string[] ProxyPathRoots = { "/api/auth", "/api/tasks", "/api/sync", "/api/security", "/api/cloud-sync" }; + + public CloudSyncProxyService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + /// + /// 获取或设置云同步服务端地址。 + /// + public string? CloudSyncUrl + { + get { lock (_lock) return _cloudSyncUrl; } + set { lock (_lock) _cloudSyncUrl = string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); } + } + + /// + /// 判断请求路径是否需要代理。 + /// + public bool ShouldProxy(string requestPath) + { + return ProxyPathRoots.Any(root => + requestPath.Equals(root, StringComparison.OrdinalIgnoreCase) + || requestPath.StartsWith($"{root}/", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 将当前请求代理转发到远程 Host,并回写响应。 + /// + public async Task ProxyAsync(HttpContext context, string targetUrl) + { + var client = _httpClientFactory.CreateClient("CloudSyncProxy"); + client.Timeout = TimeSpan.FromSeconds(30); + + var request = context.Request; + var targetUri = $"{targetUrl}{request.Path}{request.QueryString}"; + + var proxyRequest = new HttpRequestMessage(new HttpMethod(request.Method), targetUri); + + if (request.ContentLength > 0 || request.Headers.ContainsKey("Transfer-Encoding")) + { + request.EnableBuffering(); + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + request.Body.Position = 0; + proxyRequest.Content = new StringContent(body, System.Text.Encoding.UTF8, request.ContentType ?? "application/json"); + } + + if (request.Headers.TryGetValue("Authorization", out var authHeader)) + { + proxyRequest.Headers.TryAddWithoutValidation("Authorization", authHeader.ToArray()); + } + + if (request.Headers.TryGetValue("Content-Type", out var ctHeader) && proxyRequest.Content != null) + { + proxyRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ctHeader.First()!); + } + + using var response = await client.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); + + context.Response.StatusCode = (int)response.StatusCode; + context.Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"; + + var responseBody = await response.Content.ReadAsStringAsync(context.RequestAborted); + await context.Response.WriteAsync(responseBody, context.RequestAborted); + } + + /// + /// 设置持久化文件路径(由宿主在创建后调用,可选)。 + /// + public string? PersistencePath { get; set; } +} + +/// +/// CloudSyncProxyService 的 DI/管道扩展方法。 +/// +public static class CloudSyncProxyServiceExtensions +{ + /// + /// 注册云同步反向代理所需的 DI 服务(供嵌入式 MAUI/Avalonia WebServer 使用)。 + /// + public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services) + { + services.AddSingleton(); + + services.AddScoped(); + + services.AddHttpClient("CloudSyncProxy", client => + { + client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false, + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + + return services; + } + + /// + /// 注册云同步反向代理管道中间件。 + /// + public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder) + { + return builder.Use(async (context, next) => + { + var proxyService = context.RequestServices.GetRequiredService(); + var requestPath = context.Request.Path.Value ?? string.Empty; + + if (!proxyService.ShouldProxy(requestPath)) + { + await next(); + return; + } + + var targetUrl = proxyService.CloudSyncUrl; + + if (string.IsNullOrEmpty(targetUrl)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"error\":\"cloud sync server URL not configured\"}"); + return; + } + + await proxyService.ProxyAsync(context, targetUrl); + }); + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxySettingsService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxySettingsService.cs new file mode 100644 index 0000000..c04cd3e --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxySettingsService.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using Hua.Todo.Application.Services.CloudSync.Services; + +namespace Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; + +/// +/// 云同步代理设置服务(Scoped)。 +/// 通过动态 API 服务接口自动暴露为 /api/cloudSyncProxySettings(GET/POST)。 +/// 依赖 管理 URL 状态,支持持久化到 appsettings.json。 +/// +public class CloudSyncProxySettingsService : ICloudSyncProxySettingsService +{ + private readonly CloudSyncProxyService _proxyService; + + public CloudSyncProxySettingsService(CloudSyncProxyService proxyService) + { + _proxyService = proxyService; + } + + /// + public Task GetAsync() + { + return Task.FromResult(new ProxySettingsDto + { + ServerUrl = _proxyService.CloudSyncUrl ?? string.Empty + }); + } + + /// + public Task SetAsync(ProxySettingsDto dto) + { + var serverUrl = dto?.ServerUrl ?? string.Empty; + _proxyService.CloudSyncUrl = serverUrl; + + PersistToAppSettings(); + + return Task.FromResult(new ProxySettingsDto + { + ServerUrl = _proxyService.CloudSyncUrl ?? string.Empty + }); + } + + /// + /// 将当前 URL 持久化到 appsettings.json 的 WebServer.CloudSyncUrl 字段。 + /// + private void PersistToAppSettings() + { + var path = _proxyService.PersistencePath; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return; + + try + { + var json = File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.NameEquals("WebServer")) + { + writer.WritePropertyName("WebServer"); + writer.WriteStartObject(); + foreach (var wsProp in prop.Value.EnumerateObject()) + { + writer.WritePropertyName(wsProp.Name); + if (wsProp.NameEquals("CloudSyncUrl")) + { + writer.WriteStringValue(_proxyService.CloudSyncUrl ?? string.Empty); + } + else + { + wsProp.Value.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + else + { + writer.WritePropertyName(prop.Name); + prop.Value.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + catch + { + // 保存失败不影响前端 + } + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudTaskSyncService.cs similarity index 77% rename from src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs rename to src/Hua.Todo.Application/Services/CloudSync/Services/CloudTaskSyncService.cs index d09608e..31ddab7 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/CloudTaskSyncService.cs @@ -1,26 +1,56 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.Data; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.Application.Services.Interfaces; using Hua.Todo.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; -namespace Hua.Todo.Application.CloudSync.Services; +namespace Hua.Todo.Application.Services.CloudSync.Services; /// /// 云同步任务服务(按用户隔离)。 /// 实现 ABP 风格审计字段和 LWW 冲突解决策略。 /// -public class CloudTaskSyncService +public class CloudTaskSyncService : ICloudTaskSyncService { private readonly TodoDbContext _dbContext; + private readonly ILogger _logger; + private readonly ICurrentUserAccessor? _currentUserAccessor; /// /// 创建 。 /// /// 数据库上下文。 - public CloudTaskSyncService(TodoDbContext dbContext) + /// 日志记录器。 + /// HTTP 上下文访问器(可选,用于从 HttpContext 获取当前用户 ID)。 + public CloudTaskSyncService( + TodoDbContext dbContext, + ILogger logger, + ICurrentUserAccessor? currentUserAccessor = null) { _dbContext = dbContext; + _logger = logger; + _currentUserAccessor = currentUserAccessor; + } + + /// + /// 获取当前用户的任务全量(含 Tombstone)。 + /// 从 HttpContext 获取用户 ID。 + /// + public Task> GetTasksAsync() + { + var userId = GetCurrentUserId(); + return GetTasksAsync(userId, CancellationToken.None); + } + + /// + /// 执行同步(增改删),并返回最新全量。 + /// 从 HttpContext 获取用户 ID。 + /// + public Task SyncAsync(SyncRequest request) + { + var userId = GetCurrentUserId(); + return SyncAsync(userId, request, CancellationToken.None); } /// @@ -31,7 +61,6 @@ public class CloudTaskSyncService /// 任务列表(含已删除任务)。 public async Task> GetTasksAsync(Guid userId, CancellationToken cancellationToken) { - // 使用 IgnoreQueryFilters 获取包括已删除任务的全量 var tasks = await _dbContext.Tasks .AsNoTracking() .IgnoreQueryFilters() @@ -65,16 +94,12 @@ public class CloudTaskSyncService await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken); } - // 保存删除操作 await _dbContext.SaveChangesAsync(cancellationToken); } // 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理) if (request.Upserts.Count > 0) { - // 去重策略: - // - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送) - // - 全新任务(Id == null):全部保留,不参与去重 var dedupedById = request.Upserts .Where(u => u.Id.HasValue) .GroupBy(u => u.Id!.Value) @@ -86,13 +111,9 @@ public class CloudTaskSyncService var uniqueUpserts = dedupedById .Concat(newTasks) .Where(u => !string.IsNullOrWhiteSpace(u.Title)) - .OrderByDescending(u => u.LastModificationTime) // 较新的先处理 + .OrderByDescending(u => u.LastModificationTime) .ToList(); - // 使用事务包裹,确保读取与写入的一致性 - // 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突 - // ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景; - // 对于极端并发写入冲突,SaveChangesAsync 处有重试保护 const int maxSaveRetries = 3; for (int retry = 0; ; retry++) @@ -101,31 +122,25 @@ public class CloudTaskSyncService try { - // 在事务内查询已有任务,确保读取到最新已提交数据 var existingTasks = await _dbContext.Tasks .IgnoreQueryFilters() .Where(t => t.UserId == userId) .ToDictionaryAsync(t => t.Id, cancellationToken); - // 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射) var clientIdToEntity = new Dictionary(); - // 第一遍:处理根任务(ParentTaskId == null) foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue)) { await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken); } - // 保存根任务,获取服务器分配的 ID await _dbContext.SaveChangesAsync(cancellationToken); - // 将新创建的根任务加入 existingTasks,供子任务反查 foreach (var kvp in clientIdToEntity) { existingTasks[kvp.Value.Id] = kvp.Value; } - // 第二遍:处理子任务(ParentTaskId != null) foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue)) { await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken); @@ -134,27 +149,26 @@ public class CloudTaskSyncService await _dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - break; // 成功,退出重试循环 + break; } catch (DbUpdateException ex) when (retry < maxSaveRetries - 1 && ex.InnerException is Microsoft.Data.Sqlite.SqliteException se && se.SqliteErrorCode == 19 && se.Message.Contains("UNIQUE constraint")) { - // 并发写入冲突:其他事务已插入相同 Id 的任务 - // 回滚当前事务,下次循环将重新查询并正确识别已存在实体 + _logger.LogWarning(ex, "同步 SaveChanges 唯一约束冲突(重试 {Retry}/{MaxRetries}): {UserId}", retry + 1, maxSaveRetries, userId); await transaction.RollbackAsync(cancellationToken); - // 清理 ChangeTracker,避免下次循环携带已回滚的实体状态 foreach (var entry in _dbContext.ChangeTracker.Entries().ToList()) { entry.State = EntityState.Detached; } - continue; // 重试 + continue; } - catch + catch (Exception ex) { + _logger.LogError(ex, "同步 SaveChanges 失败(非可重试异常): {UserId}", userId); await transaction.RollbackAsync(cancellationToken); throw; } @@ -168,9 +182,6 @@ public class CloudTaskSyncService }; } - /// - /// 递归软删除任务及其子任务。 - /// private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken) { var task = await _dbContext.Tasks @@ -183,10 +194,8 @@ public class CloudTaskSyncService task.DeletionTime = DateTime.UtcNow; task.DeleterId = userId; - // 显式标记为已修改 _dbContext.Entry(task).State = EntityState.Modified; - // 递归删除子任务 var childTasks = await _dbContext.Tasks .IgnoreQueryFilters() .Where(t => t.ParentTaskId == taskId) @@ -199,9 +208,6 @@ public class CloudTaskSyncService } } - /// - /// 处理单个 upsert 请求(创建或更新)。 - /// private async Task ProcessUpsertAsync( Guid userId, CloudTaskUpsert upsert, @@ -209,7 +215,6 @@ public class CloudTaskSyncService Dictionary clientIdToEntity, CancellationToken cancellationToken) { - // 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID Guid? parentId = upsert.ParentTaskId; if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity)) { @@ -219,14 +224,11 @@ public class CloudTaskSyncService TaskEntity? entity = null; bool shouldAdd = false; - // 尝试从已缓存的字典中查找 TaskEntity? existingEntity = null; bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity); if (!foundInCache && upsert.Id.HasValue) { - // 字典未命中,但客户端提供了 Id —— 可能是跨请求/跨 UserId 的遗留数据 - // 直接查询 DB 以确保不会重复插入 existingEntity = await _dbContext.Tasks .IgnoreQueryFilters() .AsNoTracking() @@ -235,12 +237,10 @@ public class CloudTaskSyncService if (existingEntity != null) { - // 实体已存在(字典或 DB 中),进行 LWW 冲突判断 if (upsert.LastModificationTime.HasValue && (!existingEntity.LastModificationTime.HasValue || upsert.LastModificationTime >= existingEntity.LastModificationTime)) { - // 客户端版本更新,接受更新 entity = existingEntity; entity.Title = (upsert.Title ?? string.Empty).Trim(); entity.Priority = upsert.Priority; @@ -250,7 +250,6 @@ public class CloudTaskSyncService entity.LastModificationTime = upsert.LastModificationTime; entity.LastModifierId = userId; - // 若实体来自 AsNoTracking 查询(非 tracked),需显式附加并标记为 Modified var entry = _dbContext.Entry(entity); if (entry.State == EntityState.Detached) { @@ -262,11 +261,9 @@ public class CloudTaskSyncService entry.State = EntityState.Modified; } } - // 否则保持服务端版本不变,不做任何操作 } else { - // 确认不存在,新建任务 entity = new TaskEntity { Id = upsert.Id ?? Guid.NewGuid(), @@ -286,17 +283,14 @@ public class CloudTaskSyncService if (shouldAdd && entity != null) { - // 防御性检查:确保实体 Id 未被 ChangeTracker 跟踪,防止 UNIQUE 约束冲突 var trackedEntry = _dbContext.ChangeTracker.Entries() .FirstOrDefault(e => e.Entity.Id == entity.Id); if (trackedEntry != null) { - // 实体已被跟踪(可能来自并发操作或其他路径),使用已跟踪的实体 entity = trackedEntry.Entity; if (trackedEntry.State != EntityState.Added) { - // 仅更新非新增实体的字段 entity.Title = (upsert.Title ?? string.Empty).Trim(); entity.Priority = upsert.Priority; entity.IsCompleted = upsert.IsCompleted; @@ -312,7 +306,6 @@ public class CloudTaskSyncService _dbContext.Tasks.Add(entity); } - // 立即更新 existingTasks,避免同一批次内重复插入相同 Id existingTasks[entity.Id] = entity; if (upsert.Id.HasValue) @@ -322,9 +315,6 @@ public class CloudTaskSyncService } } - /// - /// 将实体映射到 DTO。 - /// private static CloudTaskItem MapToItem(TaskEntity task) { return new CloudTaskItem @@ -344,4 +334,13 @@ public class CloudTaskSyncService DeleterId = task.DeleterId }; } + + /// + /// 从 HttpContext 获取当前用户 ID。 + /// + private Guid GetCurrentUserId() + { + return _currentUserAccessor?.GetCurrentUserId() + ?? throw new UnauthorizedAccessException("未登录或会话已过期"); + } } \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAdminService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAdminService.cs new file mode 100644 index 0000000..57b8a76 --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAdminService.cs @@ -0,0 +1,66 @@ +using Hua.Todo.Application.Services.CloudSync.Auth; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.CloudSync.Services; + +/// +/// 管理端服务接口(账户、会话、审计日志管理)。 +/// 继承 自动暴露为 /api/admin/* 端点。 +/// 所有方法均需 users:manage 权限。 +/// 不暴露为 MCP 工具(需认证)。 +/// +[DynamicApiRoute("admin")] +[RequirePermission(CloudPermissions.UsersManage)] +public interface ICloudAdminService : IDynamicApiService +{ + /// + /// 获取所有云用户列表。 + /// GET /api/admin/users + /// + [HttpGet("users")] + Task> GetUsersAsync(); + + /// + /// 创建新用户。 + /// POST /api/admin/users + /// + [HttpPost("users")] + Task CreateUserAsync(CreateUserRequest request); + + /// + /// 重置指定用户的密码。 + /// POST /api/admin/resetPassword + /// + [HttpPost("resetPassword")] + Task ResetPasswordAsync(ResetPasswordRequest request); + + /// + /// 删除指定用户。 + /// DELETE /api/admin/users/{userId} + /// + [HttpDelete("users")] + Task DeleteUserAsync(Guid userId); + + /// + /// 获取所有活跃会话。 + /// GET /api/admin/sessions + /// + [HttpGet("sessions")] + Task> GetSessionsAsync(); + + /// + /// 吊销指定会话。 + /// DELETE /api/admin/sessions/{sessionId} + /// + [HttpDelete("sessions")] + Task RevokeSessionAsync(Guid sessionId); + + /// + /// 获取最近 N 条审计日志。 + /// GET /api/admin/auditLogs + /// + [HttpGet("auditLogs")] + Task> GetAuditLogsAsync(int? count = 100); +} diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAuthService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAuthService.cs new file mode 100644 index 0000000..5369da0 --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudAuthService.cs @@ -0,0 +1,44 @@ +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.CloudSync.Services; + +/// +/// 云同步认证服务接口。 +/// 继承 自动暴露为 /api/auth/* 端点。 +/// 不暴露为 MCP 工具(需认证)。 +/// +[DynamicApiRoute("auth")] +public interface ICloudAuthService : IDynamicApiService +{ + /// + /// 初始化系统管理员账号(仅在系统尚无云用户时可用)。 + /// POST /api/auth/bootstrap + /// + [HttpPost("bootstrap")] + [AllowAnonymous] + Task BootstrapAsync(BootstrapAdminRequest request); + + /// + /// 用户名密码登录并创建会话。 + /// POST /api/auth/login + /// + [HttpPost("login")] + [AllowAnonymous] + Task LoginAsync(LoginRequest request); + + /// + /// 登出:移除当前会话。 + /// POST /api/auth/logout + /// + [HttpPost("logout")] + Task LogoutAsync(); + + /// + /// 修改当前用户密码。 + /// POST /api/auth/change-password + /// + [HttpPost("change-password")] + Task ChangePasswordAsync(ChangePasswordRequest request); +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudProbeService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudProbeService.cs new file mode 100644 index 0000000..9237817 --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudProbeService.cs @@ -0,0 +1,23 @@ +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.CloudSync.Services; + +/// +/// 服务端探测服务接口。 +/// 继承 自动暴露为 /api/cloud-sync/* 端点。 +/// 允许匿名访问,用于客户端探测服务端可达性。 +/// 不暴露为 MCP 工具(无认证需求但内网探测能力不宜通过 MCP 暴露)。 +/// +[DynamicApiRoute("cloud-sync")] +public interface ICloudProbeService : IDynamicApiService +{ + /// + /// 探测目标 URL 的可达性。 + /// POST /api/cloud-sync/probe + /// + [AllowAnonymous] + [HttpPost("probe")] + Task ProbeAsync(ProbeRequest request); +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudSyncProxySettingsService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudSyncProxySettingsService.cs new file mode 100644 index 0000000..5157d5f --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudSyncProxySettingsService.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.CloudSync.Services; + +/// +/// 云同步代理设置服务接口。 +/// 继承 自动暴露为 HTTP API。 +/// 不暴露为 MCP 工具(CloudSync 命名空间下的接口统一排除)。 +/// +public interface ICloudSyncProxySettingsService : IDynamicApiService +{ + /// + /// 获取当前云同步服务器 URL。 + /// 路由:GET /api/cloudSyncProxySettings + /// + [Description("获取云同步服务端地址")] + Task GetAsync(); + + /// + /// 设置云同步服务器 URL 并持久化。 + /// 路由:POST /api/cloudSyncProxySettings + /// + /// 包含 serverUrl 的 DTO。 + [Description("设置云同步服务端地址")] + Task SetAsync(ProxySettingsDto dto); +} + +/// +/// 代理设置 DTO。 +/// +public class ProxySettingsDto +{ + /// + /// 云同步服务器 URL。 + /// + public string ServerUrl { get; set; } = string.Empty; +} diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudTaskSyncService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudTaskSyncService.cs new file mode 100644 index 0000000..0cd0d15 --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/ICloudTaskSyncService.cs @@ -0,0 +1,31 @@ +using Hua.Todo.Application.Services.CloudSync.Auth; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.CloudSync.Services; + +/// +/// 云同步任务服务接口。 +/// 继承 自动暴露为 /api/tasks/* 端点。 +/// 不暴露为 MCP 工具(需认证)。 +/// +[DynamicApiRoute("tasks")] +public interface ICloudTaskSyncService : IDynamicApiService +{ + /// + /// 获取当前用户的任务全量(含逻辑删除的 Tombstone)。 + /// GET /api/tasks + /// + [RequirePermission(CloudPermissions.TasksRead)] + [HttpGet("")] + Task> GetTasksAsync(); + + /// + /// 执行同步(增改删),返回最新全量。 + /// POST /api/tasks + /// + [RequirePermission(CloudPermissions.SyncWrite)] + [HttpPost("")] + Task SyncAsync(SyncRequest request); +} diff --git a/src/Hua.Todo.Application/Services/CloudSync/Services/ISecurityPolicyService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/ISecurityPolicyService.cs new file mode 100644 index 0000000..bd97524 --- /dev/null +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/ISecurityPolicyService.cs @@ -0,0 +1,31 @@ +using Hua.Todo.Application.Services.CloudSync.Auth; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.CloudSync.Services; + +/// +/// 安全策略服务接口。 +/// 继承 自动暴露为 /api/security/* 端点。 +/// 不暴露为 MCP 工具(需认证)。 +/// +[DynamicApiRoute("security")] +public interface ISecurityPolicyService : IDynamicApiService +{ + /// + /// 获取当前用户的安全策略。 + /// GET /api/security/policy + /// + [RequirePermission(CloudPermissions.PolicyRead)] + [HttpGet("policy")] + Task GetPolicyAsync(); + + /// + /// 更新当前用户的安全策略。 + /// PUT /api/security/policy + /// + [RequirePermission(CloudPermissions.PolicyWrite)] + [HttpPut("policy")] + Task UpdatePolicyAsync(UpdateSecurityPolicyRequest request); +} diff --git a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs b/src/Hua.Todo.Application/Services/CloudSync/Services/SecurityPolicyService.cs similarity index 61% rename from src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs rename to src/Hua.Todo.Application/Services/CloudSync/Services/SecurityPolicyService.cs index 806d6e5..6e64d0d 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs +++ b/src/Hua.Todo.Application/Services/CloudSync/Services/SecurityPolicyService.cs @@ -1,24 +1,46 @@ -using Microsoft.EntityFrameworkCore; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.Data; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.Application.Services.Interfaces; using Hua.Todo.Core.Entities; +using Microsoft.EntityFrameworkCore; -namespace Hua.Todo.Application.CloudSync.Services; +namespace Hua.Todo.Application.Services.CloudSync.Services; /// /// 安全策略服务(按用户隔离)。 /// -public class SecurityPolicyService +public class SecurityPolicyService : ISecurityPolicyService { private readonly TodoDbContext _dbContext; + private readonly ICurrentUserAccessor? _currentUserAccessor; /// /// 创建 。 /// /// 数据库上下文。 - public SecurityPolicyService(TodoDbContext dbContext) + /// HTTP 上下文访问器(可选,用于从 HttpContext 获取当前用户 ID)。 + public SecurityPolicyService(TodoDbContext dbContext, ICurrentUserAccessor? currentUserAccessor = null) { _dbContext = dbContext; + _currentUserAccessor = currentUserAccessor; + } + + /// + /// 获取当前用户的安全策略(从 HttpContext 获取用户 ID)。 + /// + public Task GetPolicyAsync() + { + var userId = GetCurrentUserId(); + return GetPolicyAsync(userId, CancellationToken.None); + } + + /// + /// 更新当前用户的安全策略(从 HttpContext 获取用户 ID)。 + /// + public Task UpdatePolicyAsync(UpdateSecurityPolicyRequest request) + { + var userId = GetCurrentUserId(); + return UpdatePolicyAsync(userId, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, CancellationToken.None); } /// @@ -76,4 +98,14 @@ public class SecurityPolicyService return await GetPolicyAsync(userId, cancellationToken); } -} + + /// + /// 从 HttpContext 获取当前用户 ID。 + /// + /// 未登录时抛出。 + private Guid GetCurrentUserId() + { + return _currentUserAccessor?.GetCurrentUserId() + ?? throw new UnauthorizedAccessException("未登录或会话已过期"); + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/IAttachmentService.cs b/src/Hua.Todo.Application/Services/IAttachmentService.cs new file mode 100644 index 0000000..579ce79 --- /dev/null +++ b/src/Hua.Todo.Application/Services/IAttachmentService.cs @@ -0,0 +1,62 @@ +using Hua.Todo.HttpApi; +using Hua.Todo.Application.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services; + +/// +/// 附件管理服务接口,继承 自动暴露为 HTTP API 和 MCP 工具。 +/// +[RemoteService(IsEnabled = true)] +public interface IAttachmentService : IDynamicApiService +{ + /// + /// 上传文件附件。 + /// POST /api/attachment/upload + /// + /// 上传请求,包含 Base64 编码的文件内容。 + /// 上传后的附件 DTO。 + [System.ComponentModel.Description("上传文件附件")] + [HttpPost("upload")] + Task UploadAsync(UploadAttachmentRequest request); + + /// + /// 添加外部链接附件。 + /// POST /api/attachment/addLink + /// + /// 添加链接请求,包含 URL 和可选名称。 + /// 创建的附件 DTO。 + [System.ComponentModel.Description("添加外部链接附件")] + [HttpPost("addLink")] + Task AddLinkAsync(AddLinkRequest request); + + /// + /// 获取待办项的附件列表。 + /// GET /api/attachment/list + /// + /// 待办项 ID。 + /// 附件 DTO 列表。 + [System.ComponentModel.Description("获取待办项的附件列表")] + [HttpGet] + Task> GetAttachmentsAsync(Guid taskId); + + /// + /// 删除附件。 + /// DELETE /api/attachment/delete + /// + /// 附件 ID。 + [System.ComponentModel.Description("删除附件")] + [HttpDelete] + Task DeleteAsync(Guid id); + + /// + /// 打开附件(调用系统关联程序打开文件或用浏览器打开 URL)。 + /// POST /api/attachment/open + /// + /// 附件 ID。 + /// 打开结果。 + [System.ComponentModel.Description("打开附件")] + [HttpPost("open")] + Task OpenAsync(Guid id); +} diff --git a/src/Hua.Todo.Application/Services/Interfaces/ICurrentUserAccessor.cs b/src/Hua.Todo.Application/Services/Interfaces/ICurrentUserAccessor.cs new file mode 100644 index 0000000..404f526 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Interfaces/ICurrentUserAccessor.cs @@ -0,0 +1,34 @@ +namespace Hua.Todo.Application.Services.Interfaces; + +/// +/// 当前用户访问器抽象。 +/// 隔离 I/O 层(HttpContext)对业务层的直接依赖。 +/// 由宿主层(HttpApi.AspNetCore / MAUI / Avalonia)注入对应平台实现。 +/// 对于无用户上下文的场景(如匿名请求),所有方法返回 null。 +/// +public interface ICurrentUserAccessor +{ + /// + /// 获取当前用户 ID。 + /// 若当前无用户上下文则返回 null。 + /// + Guid? GetCurrentUserId(); + + /// + /// 获取当前会话 ID(Bearer Token 对应的 SessionId)。 + /// 若当前无会话上下文则返回 null。 + /// + Guid? GetCurrentSessionId(); + + /// + /// 获取客户端 IP 地址。 + /// 若无法获取则返回 null。 + /// + string? GetClientIp(); + + /// + /// 获取客户端 User-Agent。 + /// 若无法获取则返回 null。 + /// + string? GetUserAgent(); +} diff --git a/src/Hua.Todo.Application/Interfaces/ITaskService.cs b/src/Hua.Todo.Application/Services/Interfaces/ITaskService.cs similarity index 87% rename from src/Hua.Todo.Application/Interfaces/ITaskService.cs rename to src/Hua.Todo.Application/Services/Interfaces/ITaskService.cs index d5df029..1f440e6 100644 --- a/src/Hua.Todo.Application/Interfaces/ITaskService.cs +++ b/src/Hua.Todo.Application/Services/Interfaces/ITaskService.cs @@ -1,6 +1,7 @@ using Hua.Todo.Application.Models; +using Hua.Todo.HttpApi.Interfaces; -namespace Hua.Todo.Application.Interfaces; +namespace Hua.Todo.Application.Services.Interfaces; /// /// 任务管理服务接口 @@ -18,7 +19,7 @@ public interface ITaskService : IDynamicApiService /// /// 任务唯一标识符 /// 匹配的任务 DTO,如果未找到则返回 null - Task GetTaskByIdAsync(int id); + Task GetTaskByIdAsync(Guid id); /// /// 获取未完成的任务 @@ -51,18 +52,18 @@ public interface ITaskService : IDynamicApiService /// /// 任务唯一标识符 /// 更新状态后的任务 DTO - Task ToggleCompleteAsync(int id); + Task ToggleCompleteAsync(Guid id); /// /// 删除任务 /// /// 要删除的任务唯一标识符 - Task DeleteTaskAsync(int id); + Task DeleteTaskAsync(Guid id); /// /// 获取子任务列表 /// /// 父任务唯一标识符 /// 该父任务下的所有子任务列表 - Task> GetSubTasksAsync(int parentTaskId); + Task> GetSubTasksAsync(Guid parentTaskId); } diff --git a/src/Hua.Todo.Application/Services/Meeting/IMeetingService.cs b/src/Hua.Todo.Application/Services/Meeting/IMeetingService.cs new file mode 100644 index 0000000..85bdf7d --- /dev/null +++ b/src/Hua.Todo.Application/Services/Meeting/IMeetingService.cs @@ -0,0 +1,62 @@ +using Hua.Todo.Application.Services.Meeting.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.Meeting; + +/// +/// 会议业务服务接口,继承 自动暴露为 HTTP API 和 MCP 工具。 +/// +[RemoteService(IsEnabled = true)] +public interface IMeetingService : IDynamicApiService +{ + /// + /// 保存/更新会议纪要,仅限 Meeting 类型的待办项。 + /// POST /api/meeting/saveNotes + /// + /// 保存纪要请求,包含 taskId 和纪要内容。 + /// 保存结果。 + [System.ComponentModel.Description("保存/更新会议纪要")] + [HttpPost("saveNotes")] + Task SaveNotesAsync(SaveNotesRequest request); + + /// + /// 获取会议详情(含纪要、录音时长等)。 + /// GET /api/meeting/{taskId}/getMeetingDetail + /// + /// 待办项 ID。 + /// 会议详情。 + [System.ComponentModel.Description("获取会议详情")] + [HttpGet] + Task GetMeetingDetailAsync(Guid taskId); + + /// + /// 上传录音文件进行语音转写,结果自动保存到会议纪要。 + /// POST /api/meeting/transcribeAudio + /// + /// 转写请求,包含 taskId、Base64 音频数据和格式。 + /// 转写结果,包含转写文字和音频时长。 + [System.ComponentModel.Description("上传录音文件进行语音转写")] + [HttpPost("transcribeAudio")] + Task TranscribeAudioAsync(TranscribeRequest request); + + /// + /// AI 拆分会议为待办项建议。 + /// POST /api/meeting/breakdown + /// + /// 拆分请求,包含 taskId 和可选会议纪要文字。 + /// 拆分建议列表。 + [System.ComponentModel.Description("AI 拆分会议为待办项建议")] + [HttpPost("breakdown")] + Task RequestBreakdownAsync(BreakdownRequest request); + + /// + /// 确认 AI 拆分建议并批量创建子任务。 + /// POST /api/meeting/confirmBreakdown + /// + /// 确认请求,包含 taskId 和选定的子任务列表。 + /// 批量创建结果。 + [System.ComponentModel.Description("确认 AI 拆分建议并批量创建子任务")] + [HttpPost("confirmBreakdown")] + Task ConfirmBreakdownAsync(ConfirmBreakdownRequest request); +} diff --git a/src/Hua.Todo.Application/Services/Meeting/ISttService.cs b/src/Hua.Todo.Application/Services/Meeting/ISttService.cs new file mode 100644 index 0000000..37498df --- /dev/null +++ b/src/Hua.Todo.Application/Services/Meeting/ISttService.cs @@ -0,0 +1,17 @@ +namespace Hua.Todo.Application.Services.Meeting; + +/// +/// 语音转写服务接口。 +/// 将音频文件流转写为文字,供会议录音转写使用。 +/// +public interface ISttService +{ + /// + /// 将音频文件转写为文字。 + /// + /// 音频文件流。 + /// 音频格式,如 "webm"、"mp4"、"wav"。 + /// 取消令牌。 + /// 转写后的文字内容。 + Task TranscribeAsync(Stream audioStream, string format, CancellationToken ct = default); +} diff --git a/src/Hua.Todo.Application/Services/Meeting/MeetingAiBreakdownService.cs b/src/Hua.Todo.Application/Services/Meeting/MeetingAiBreakdownService.cs new file mode 100644 index 0000000..3956af6 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Meeting/MeetingAiBreakdownService.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Interfaces; +using Hua.Todo.Application.Services.Meeting.Models; +using Hua.Todo.Application.Services.Voice; +using Hua.Todo.Core.Entities; +using Hua.Todo.Core.Interfaces; + +namespace Hua.Todo.Application.Services.Meeting; + +/// +/// 会议 AI 拆分服务,将会议文字内容分析为待办项建议。 +/// 复用工单 02 的 进行 LLM 调用。 +/// +public class MeetingAiBreakdownService +{ + private readonly ILlmClientService _llmClient; + private readonly ITaskRepository _taskRepo; + private readonly ITaskService _taskService; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + /// + /// 最大可提交给 LLM 的会议文本长度,超长则截断。 + /// + private const int MaxMeetingTextLength = 4000; + + /// + /// 初始化 实例。 + /// + /// LLM 客户端,用于调用 AI 分析。 + /// 任务仓储,用于查找与验证。 + /// 任务服务,用于批量创建子任务。 + public MeetingAiBreakdownService( + ILlmClientService llmClient, + ITaskRepository taskRepo, + ITaskService taskService) + { + _llmClient = llmClient; + _taskRepo = taskRepo; + _taskService = taskService; + } + + /// + /// 分析会议内容,调用 LLM 返回待办项建议列表。 + /// + /// 会议待办项 ID。 + /// 会议纪要文字(可选,不传则使用已保存的 meetingNotes)。 + /// 取消令牌。 + /// 待办项建议列表。 + /// 当 taskId 不存在时抛出。 + /// 当待办项不是会议类型或内容为空时抛出。 + /// 当 LLM API 调用失败时抛出。 + public async Task> AnalyzeAsync( + Guid taskId, string? notes = null, CancellationToken ct = default) + { + var task = await _taskRepo.GetByIdAsync(taskId); + if (task == null) + throw new KeyNotFoundException($"待办项 {taskId} 不存在"); + if (task.TaskType != TaskType.Meeting) + throw new InvalidOperationException("该待办项不是会议类型"); + + var meetingText = notes ?? task.MeetingNotes; + if (string.IsNullOrWhiteSpace(meetingText)) + throw new InvalidOperationException("会议内容为空,请先输入纪要或上传录音"); + + var prompt = BuildBreakdownPrompt(meetingText, task.Title); + var response = await _llmClient.SendAsync(prompt, ct); + + return ParseBreakdownJson(response); + } + + /// + /// 确认 AI 拆分建议并批量创建子任务。 + /// + /// 会议待办项 ID。 + /// 要创建的子任务列表。 + /// 取消令牌。 + /// 批量创建结果。 + /// 当 taskId 不存在时抛出。 + /// 当待办项不是会议类型或子任务列表为空时抛出。 + public async Task ConfirmAndCreateAsync( + Guid taskId, List subTasks, CancellationToken ct = default) + { + var task = await _taskRepo.GetByIdAsync(taskId); + if (task == null) + throw new KeyNotFoundException($"待办项 {taskId} 不存在"); + if (task.TaskType != TaskType.Meeting) + throw new InvalidOperationException("该待办项不是会议类型"); + + if (subTasks == null || subTasks.Count == 0) + throw new InvalidOperationException("请至少选择一个待办项"); + + var created = new List(); + foreach (var item in subTasks) + { + var dto = new CreateTaskDto + { + Title = item.Title, + Priority = item.Priority, + ParentTaskId = taskId, + TaskType = TaskType.Normal + }; + created.Add(await _taskService.CreateTaskAsync(dto)); + } + + return new BatchCreateResult { CreatedCount = created.Count, SubTasks = created }; + } + + /// + /// 构造会议拆分专用的 LLM prompt。 + /// + /// 会议纪要文字。 + /// 会议标题。 + /// 完整的 prompt 文本。 + private static string BuildBreakdownPrompt(string meetingText, string title) + { + var truncatedText = meetingText.Length > MaxMeetingTextLength + ? meetingText.Substring(0, MaxMeetingTextLength) + : meetingText; + + return string.Join("\n", + "你是专业的项目管理和会议纪要分析助手。根据用户提供的会议内容,提取所有需要", + "后续行动的事项,生成待办项建议列表。", + "", + "要求:", + "1. 每条建议包含标题(title)、优先级(priority)、原因(reason)", + "2. 标题应简洁明确(15 字以内),如\"整理需求文档\"、\"安排评审会议\"", + "3. 优先级:High(2)=紧急重要,Medium(1)=一般,Low(0)=可延迟", + "4. 原因应解释为什么需要这个待办项,引用会议中的具体决策或讨论", + "5. 只提取明确需要执行的事项,不要生成模糊或无来源的建议", + "6. 建议数量根据会议内容合理判断,通常 3-10 条", + "7. 如果会议内容无法提取出明确的行动项,返回空列表", + "", + $"会议标题:{title}", + "", + "会议内容:", + truncatedText, + "", + "输出格式(只输出 JSON,不要解释):", + "{", + " \"suggestions\": [", + " {", + " \"title\": \"待办项标题\",", + " \"priority\": 1,", + " \"reason\": \"原因说明\"", + " }", + " ]", + "}"); + } + + /// + /// 解析 LLM 返回的 JSON 响应为建议列表(公开方法,便于测试)。 + /// + /// LLM 返回的原始文本。 + /// 解析后的建议列表。 + public static List ParseBreakdownJson(string llmResponse) + { + var json = CleanJsonResponse(llmResponse); + LlBreakdownResponse? result; + try + { + result = JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (JsonException) + { + return new List(); + } + + if (result?.Suggestions == null || result.Suggestions.Count == 0) + return new List(); + + return result.Suggestions + .Where(s => !string.IsNullOrWhiteSpace(s.Title)) + .Select(s => new MeetingTaskSuggestion + { + Title = s.Title.Trim(), + Priority = s.Priority, + Reason = s.Reason?.Trim() ?? string.Empty + }) + .ToList(); + } + + /// + /// 清理 LLM 响应文本:移除可能的 markdown 代码块包裹、前后空白。 + /// + /// 原始 LLM 响应。 + /// 清理后的 JSON 字符串。 + private static string CleanJsonResponse(string response) + { + var json = response.Trim(); + + // 移除 markdown 代码块包裹 ```json ... ``` + if (json.StartsWith("```")) + { + var startIndex = json.IndexOf('\n'); + if (startIndex > 0) + { + json = json.Substring(startIndex + 1); + } + + var endIndex = json.LastIndexOf("```"); + if (endIndex > 0) + { + json = json.Substring(0, endIndex); + } + } + + return json.Trim(); + } + + /// + /// LLM 原始响应结构(内部使用,仅用于 JSON 反序列化)。 + /// + private class LlBreakdownResponse + { + public List Suggestions { get; set; } = new(); + } +} diff --git a/src/Hua.Todo.Application/Services/Meeting/MeetingService.cs b/src/Hua.Todo.Application/Services/Meeting/MeetingService.cs new file mode 100644 index 0000000..20076d1 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Meeting/MeetingService.cs @@ -0,0 +1,149 @@ +using Hua.Todo.Application.Services.Meeting.Models; +using Hua.Todo.Core.Entities; +using Hua.Todo.Core.Interfaces; + +namespace Hua.Todo.Application.Services.Meeting; + +/// +/// 会议业务服务实现,继承 对外暴露为 Dynamic API 和 MCP 工具。 +/// 负责会议纪要保存、音频转录、AI 任务拆分与确认创建的全流程。 +/// +public class MeetingService : IMeetingService +{ + private readonly ITaskRepository _taskRepository; + private readonly ISttService _sttService; + private readonly MeetingAiBreakdownService _breakdownService; + + /// + /// 初始化 实例。 + /// + /// 待办项仓储,用于查询与更新 Meeting 类型的待办项。 + /// 语音转写服务,将音频流转换为文字。 + /// AI 会议拆分服务,调用 LLM 分析会议内容并拆分待办项。 + public MeetingService(ITaskRepository taskRepository, ISttService sttService, MeetingAiBreakdownService breakdownService) + { + _taskRepository = taskRepository; + _sttService = sttService; + _breakdownService = breakdownService; + } + + /// + /// 按 taskId 查询待办项并验证其类型为 Meeting,验证失败时抛出对应异常。 + /// 多个公开方法共用此校验逻辑,避免重复的 null 检查和类型判断。 + /// + /// 待办项 ID。 + /// 验证通过的 TaskEntity 实例。 + /// 当 taskId 对应的待办项不存在时抛出。 + /// 当待办项存在但 TaskType 不是 Meeting 时抛出。 + private async Task GetMeetingTaskOrThrow(Guid taskId) + { + var task = await _taskRepository.GetByIdAsync(taskId); + if (task == null) + throw new KeyNotFoundException($"待办项 {taskId} 不存在"); + if (task.TaskType != TaskType.Meeting) + throw new InvalidOperationException($"待办项 {taskId} 不是会议类型"); + return task; + } + + /// + /// + /// 直接覆盖写入会议纪要,不做追加。如需追加场景请使用 。 + /// + public async Task SaveNotesAsync(SaveNotesRequest request) + { + var task = await GetMeetingTaskOrThrow(request.TaskId); + task.MeetingNotes = request.Notes; + task.LastModificationTime = DateTime.UtcNow; + await _taskRepository.UpdateAsync(task); + + return new SaveNotesResponse + { + TaskId = task.Id, + MeetingNotes = task.MeetingNotes ?? string.Empty + }; + } + + /// + /// + /// 只返回会议维度的信息(纪要、时长、完成状态),不含子任务递归。 + /// + public async Task GetMeetingDetailAsync(Guid taskId) + { + var task = await GetMeetingTaskOrThrow(taskId); + return new MeetingDetailResponse + { + TaskId = task.Id, + Title = task.Title, + MeetingNotes = task.MeetingNotes, + AudioDuration = task.AudioDuration, + IsCompleted = task.IsCompleted + }; + } + + /// + /// + /// 转写结果以追加方式写入 MeetingNotes,支持同一会议多次录音的累积场景。 + /// + public async Task TranscribeAudioAsync(TranscribeRequest request) + { + var task = await GetMeetingTaskOrThrow(request.TaskId); + + if (string.IsNullOrEmpty(request.AudioBase64)) + throw new ArgumentException("音频数据不能为空"); + + // Base64 传输避免 DynamicApi 中间件的 multipart/form-data 兼容问题 + var audioBytes = Convert.FromBase64String(request.AudioBase64); + if (audioBytes.Length == 0) + throw new ArgumentException("音频数据为空"); + + // 限制单次录音时长:webm 格式 2 小时约 200MB,取 256MB 作为安全上限 + if (audioBytes.Length > 256 * 1024 * 1024) + throw new ArgumentException("音频文件过大,请控制录音在 2 小时以内"); + + string transcript; + using (var stream = new MemoryStream(audioBytes)) + { + transcript = await _sttService.TranscribeAsync(stream, request.Format); + } + + // 多次录音的转写结果以追加方式累积,用双换行分隔不同段落的录音 + task.MeetingNotes = string.IsNullOrEmpty(task.MeetingNotes) + ? transcript + : task.MeetingNotes + "\n\n" + transcript; + task.AudioDuration = request.AudioDuration; + task.LastModificationTime = DateTime.UtcNow; + await _taskRepository.UpdateAsync(task); + + return new TranscribeResponse + { + TaskId = task.Id, + Transcript = transcript, + AudioDuration = request.AudioDuration + }; + } + + /// + /// + /// 委托 调用 LLM 分析会议内容,返回待办项建议列表。 + /// 当 notes 参数为空时将使用已保存的 MeetingNotes 作为分析文本。 + /// + public async Task RequestBreakdownAsync(BreakdownRequest request) + { + var suggestions = await _breakdownService.AnalyzeAsync(request.TaskId, request.Notes); + return new BreakdownResponse + { + TaskId = request.TaskId, + Suggestions = suggestions + }; + } + + /// + /// + /// 委托 批量创建子任务, + /// 所有子任务自动设为 类型并以当前会议为父任务。 + /// + public async Task ConfirmBreakdownAsync(ConfirmBreakdownRequest request) + { + return await _breakdownService.ConfirmAndCreateAsync(request.TaskId, request.SubTasks); + } +} diff --git a/src/Hua.Todo.Application/Services/Meeting/Models/MeetingDtos.cs b/src/Hua.Todo.Application/Services/Meeting/Models/MeetingDtos.cs new file mode 100644 index 0000000..443b7d0 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Meeting/Models/MeetingDtos.cs @@ -0,0 +1,201 @@ +using System.ComponentModel.DataAnnotations; +using Hua.Todo.Application.Models; +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.Services.Meeting.Models; + +/// +/// 保存会议纪要请求。 +/// +public class SaveNotesRequest +{ + /// + /// 待办项 ID。 + /// + [Required] + public Guid TaskId { get; set; } + + /// + /// 会议纪要/转写文字内容。 + /// + [Required] + [MaxLength(20000)] + public string Notes { get; set; } = string.Empty; +} + +/// +/// 保存会议纪要响应。 +/// +public class SaveNotesResponse +{ + /// + /// 待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 保存后的会议纪要内容。 + /// + public string MeetingNotes { get; set; } = string.Empty; +} + +/// +/// 会议详情响应(不含子任务递归)。 +/// +public class MeetingDetailResponse +{ + /// + /// 待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 会议标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 会议纪要/转写文字。 + /// + public string? MeetingNotes { get; set; } + + /// + /// 录音时长(秒)。 + /// + public double? AudioDuration { get; set; } + + /// + /// 是否已完成。 + /// + public bool IsCompleted { get; set; } +} + +/// +/// 会议任务拆分建议(LLM 输出)。 +/// +public class MeetingTaskSuggestion +{ + /// 建议的待办项标题 + public string Title { get; set; } = string.Empty; + + /// 建议优先级 + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + + /// 拆分原因(LLM 输出,用于 UI 展示) + public string Reason { get; set; } = string.Empty; +} + +/// +/// AI 拆分请求。 +/// +public class BreakdownRequest +{ + /// 待办项 ID + [Required] + public Guid TaskId { get; set; } + + /// 会议纪要文字(可选,不传则使用已保存的 meetingNotes) + public string? Notes { get; set; } +} + +/// +/// AI 拆分响应。 +/// +public class BreakdownResponse +{ + /// 待办项 ID + public Guid TaskId { get; set; } + + /// 拆分建议列表 + public List Suggestions { get; set; } = new(); +} + +/// +/// 批量创建请求中的单个子任务项。 +/// +public class SubTaskCreateItem +{ + /// 子任务标题 + [Required] + public string Title { get; set; } = string.Empty; + + /// 子任务优先级 + public TaskPriority Priority { get; set; } = TaskPriority.Medium; +} + +/// +/// 批量确认拆分请求。 +/// +public class ConfirmBreakdownRequest +{ + /// 待办项 ID + [Required] + public Guid TaskId { get; set; } + + /// 要创建的子任务列表 + [Required] + public List SubTasks { get; set; } = new(); +} + +/// +/// 批量创建结果。 +/// +public class BatchCreateResult +{ + /// 成功创建数量 + public int CreatedCount { get; set; } + + /// 创建后的子任务 DTO 列表 + public List SubTasks { get; set; } = new(); +} + +/// +/// 语音转写请求。 +/// 音频数据以 Base64 编码在 JSON 中传输,避免 DynamicApi 中间件处理 multipart/form-data 的兼容问题。 +/// +public class TranscribeRequest +{ + /// + /// 待办项 ID。 + /// + [Required] + public Guid TaskId { get; set; } + + /// + /// Base64 编码的音频数据。 + /// + [Required] + public string AudioBase64 { get; set; } = string.Empty; + + /// + /// 音频格式,例如 "webm"、"mp4"、"wav"。 + /// + [Required] + public string Format { get; set; } = string.Empty; + + /// + /// 音频时长(秒),由前端录制时计算。 + /// + public double AudioDuration { get; set; } +} + +/// +/// 语音转写响应。 +/// +public class TranscribeResponse +{ + /// + /// 待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 转写后的文字内容。 + /// + public string Transcript { get; set; } = string.Empty; + + /// + /// 音频时长(秒)。 + /// + public double AudioDuration { get; set; } +} diff --git a/src/Hua.Todo.Application/Services/Meeting/SttService.cs b/src/Hua.Todo.Application/Services/Meeting/SttService.cs new file mode 100644 index 0000000..ebab7a9 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Meeting/SttService.cs @@ -0,0 +1,24 @@ +namespace Hua.Todo.Application.Services.Meeting; + +/// +/// 语音转写服务占位实现。 +/// 当前返回占位文本,后续版本将通过 LLM Whisper API 或平台原生 API 实现真实转写。 +/// +public class SttService : ISttService +{ + /// + public async Task TranscribeAsync(Stream audioStream, string format, CancellationToken ct = default) + { + // 占位实现:读取流长度后返回占位文本 + var buffer = new byte[1024]; + var totalRead = 0; + int read; + while ((read = await audioStream.ReadAsync(buffer, 0, buffer.Length, ct)) > 0) + { + totalRead += read; + } + + // 返回占位转写结果,后续版本替换为真实 STT 调用 + return $"[语音转写服务暂未部署] 已接收 {format} 格式音频 {totalRead} 字节,请手动输入会议纪要。"; + } +} diff --git a/src/Hua.Todo.Application/Services/TaskService.cs b/src/Hua.Todo.Application/Services/TaskService.cs index 494a9ea..f6ca307 100644 --- a/src/Hua.Todo.Application/Services/TaskService.cs +++ b/src/Hua.Todo.Application/Services/TaskService.cs @@ -1,24 +1,27 @@ -using Hua.Todo.Application.Interfaces; using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Interfaces; using Hua.Todo.Core.Entities; using Hua.Todo.Core.Interfaces; namespace Hua.Todo.Application.Services; /// -/// 任务管理服务实现 +/// 任务管理服务实现。 /// public class TaskService : ITaskService { private readonly ITaskRepository _taskRepository; + private readonly IAttachmentRepository? _attachmentRepository; /// - /// 初始化任务管理服务的新实例 + /// 初始化任务管理服务的新实例。 /// - /// 任务仓储接口 - public TaskService(ITaskRepository taskRepository) + /// 任务仓储接口。 + /// 附件仓储接口(可选,静态服务端可能未注册)。 + public TaskService(ITaskRepository taskRepository, IAttachmentRepository? attachmentRepository = null) { _taskRepository = taskRepository; + _attachmentRepository = attachmentRepository; } /// @@ -36,7 +39,7 @@ public class TaskService : ITaskService /// /// 任务 ID /// 找到的任务 DTO,如果不存在则返回 null - public async Task GetTaskByIdAsync(int id) + public async Task GetTaskByIdAsync(Guid id) { var task = await _taskRepository.GetByIdAsync(id); return task != null ? MapToDto(task) : null; @@ -69,14 +72,18 @@ public class TaskService : ITaskService /// 新创建的任务 DTO public async Task CreateTaskAsync(CreateTaskDto dto) { + var maxCode = await _taskRepository.GetMaxCodeAsync(TodoUserIds.LocalUserId); var task = new TaskEntity { + Id = Guid.NewGuid(), + UserId = TodoUserIds.LocalUserId, Title = dto.Title, Priority = dto.Priority, + Code = (maxCode + 1).ToString(), IsCompleted = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - ParentTaskId = dto.ParentTaskId + ParentTaskId = dto.ParentTaskId, + TaskType = dto.TaskType, + Description = dto.Description }; var createdTask = await _taskRepository.AddAsync(task); @@ -107,7 +114,17 @@ public class TaskService : ITaskService task.Priority = dto.Priority.Value; } - task.UpdatedAt = DateTime.UtcNow; + if (dto.TaskType.HasValue) + { + task.TaskType = dto.TaskType.Value; + } + + // Description: 不为 null/空则更新,传递 null 表示不修改(保持原值) + if (dto.Description != null) + { + // 空字符串视为清空 + task.Description = string.IsNullOrEmpty(dto.Description) ? null : dto.Description; + } var updatedTask = await _taskRepository.UpdateAsync(task); return MapToDto(updatedTask); @@ -119,7 +136,7 @@ public class TaskService : ITaskService /// 任务 ID /// 状态切换后的任务 DTO /// 当任务 ID 不存在时抛出 - public async Task ToggleCompleteAsync(int id) + public async Task ToggleCompleteAsync(Guid id) { var task = await _taskRepository.GetByIdAsync(id); if (task == null) @@ -128,7 +145,6 @@ public class TaskService : ITaskService } task.IsCompleted = !task.IsCompleted; - task.UpdatedAt = DateTime.UtcNow; var updatedTask = await _taskRepository.UpdateAsync(task); return MapToDto(updatedTask); @@ -139,7 +155,7 @@ public class TaskService : ITaskService /// /// 任务 ID /// 当任务 ID 不存在时抛出 - public async Task DeleteTaskAsync(int id) + public async Task DeleteTaskAsync(Guid id) { var task = await _taskRepository.GetByIdAsync(id); if (task == null) @@ -155,26 +171,31 @@ public class TaskService : ITaskService /// /// 父任务 ID /// 子任务的 DTO 列表 - public async Task> GetSubTasksAsync(int parentTaskId) + public async Task> GetSubTasksAsync(Guid parentTaskId) { var allTasks = await _taskRepository.GetAllAsync(); return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList(); } /// - /// 实体转 DTO 映射方法 + /// 实体转 DTO 映射方法。 /// private TaskDto MapToDto(TaskEntity task) { return new TaskDto { Id = task.Id, + Code = task.Code, Title = task.Title, Priority = task.Priority, IsCompleted = task.IsCompleted, - CreatedAt = task.CreatedAt, - UpdatedAt = task.UpdatedAt, + CreatedAt = task.CreationTime, + UpdatedAt = task.LastModificationTime ?? task.CreationTime, ParentTaskId = task.ParentTaskId, + TaskType = task.TaskType, + MeetingNotes = task.MeetingNotes, + AudioDuration = task.AudioDuration, + Description = task.Description, SubTasks = task.SubTasks.Select(MapToDto).ToList() }; } diff --git a/src/Hua.Todo.Application/Services/Voice/AiBreakdownService.cs b/src/Hua.Todo.Application/Services/Voice/AiBreakdownService.cs new file mode 100644 index 0000000..fa678e2 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/AiBreakdownService.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Interfaces; +using Hua.Todo.Application.Services.Voice.Models; +using Hua.Todo.Core.Entities; +using Microsoft.Extensions.Logging; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// AI 任务拆分服务。 +/// 通过 LLM 分析父任务标题,生成子任务拆分建议,经用户确认后批量创建子任务。 +/// +public class AiBreakdownService +{ + private readonly ILlmClientService _llmClient; + private readonly ITaskService _taskService; + private readonly ILogger _logger; + + /// + /// AI 拆分专用 system prompt。 + /// 指示 LLM 分析任务并输出结构化子任务建议。 + /// + private const string BreakdownPrompt = """ + 你是一个任务管理专家。根据用户提供的任务标题,分析并拆分出 3-10 个子任务建议。 + 每个子任务应具体、可执行。 + 输出以下 JSON 格式(只输出 JSON,不要解释): + { + "suggestions": [ + { "title": "子任务标题", "priority": "High|Medium|Low", "reason": "建议理由" } + ] + } + """; + + /// + /// 创建 实例。 + /// + /// LLM 客户端服务。 + /// 任务服务。 + /// 日志记录器。 + public AiBreakdownService(ILlmClientService llmClient, ITaskService taskService, ILogger logger) + { + _llmClient = llmClient; + _taskService = taskService; + _logger = logger; + } + + /// + /// 获取任务拆分建议。 + /// 通过 LLM 分析父任务标题,生成 3-10 条结构化子任务建议。 + /// + /// 拆分请求,包含目标任务 ID。 + /// 取消令牌。 + /// AI 拆分响应,包含建议列表。 + public async Task GetBreakdownAsync(AiBreakdownRequest request, CancellationToken ct = default) + { + var response = new AiBreakdownResponse(); + + var task = await _taskService.GetTaskByIdAsync(request.TaskId); + if (task == null) + { + return response; // 空建议列表 + } + + try + { + var prompt = $"{BreakdownPrompt}\n\n任务标题:{task.Title}"; + var llmResponse = await _llmClient.SendAsync(prompt, ct); + var suggestions = ParseBreakdownResponse(llmResponse); + + // 限制建议数量:3-10 条 + response.Suggestions = suggestions.Take(10).Where(s => !string.IsNullOrWhiteSpace(s.Title)).ToList(); + + if (response.Suggestions.Count < 3 && suggestions.Count > 0) + { + // LLM 返回不足 3 条,用原始结果 + response.Suggestions = suggestions.Where(s => !string.IsNullOrWhiteSpace(s.Title)).ToList(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AI 任务拆分 LLM 调用失败: {TaskTitle}", task.Title); + // LLM 调用失败时返回空列表,由调用方处理 + } + + return response; + } + + /// + /// 确认拆分建议并批量创建子任务。 + /// + /// 确认请求,包含父任务 ID 和选定的子任务列表。 + /// 创建后的子任务 DTO 列表。 + public async Task> ConfirmBreakdownAsync(AiBreakdownConfirmRequest request) + { + var created = new List(); + + foreach (var item in request.SubTasks) + { + if (string.IsNullOrWhiteSpace(item.Title)) + continue; + + var dto = new CreateTaskDto + { + Title = item.Title, + Priority = item.Priority, + ParentTaskId = request.ParentTaskId + }; + + var createdTask = await _taskService.CreateTaskAsync(dto); + created.Add(createdTask); + } + + return created; + } + + /// + /// 解析 LLM 返回的 JSON 为子任务建议列表。 + /// + private static List ParseBreakdownResponse(string llmResponse) + { + try + { + var json = llmResponse.Trim(); + if (json.StartsWith("```")) + { + var lines = json.Split('\n', StringSplitOptions.RemoveEmptyEntries); + json = string.Join("\n", lines.Skip(1).TakeWhile(l => !l.Trim().StartsWith("```"))).Trim(); + } + + using var doc = JsonDocument.Parse(json); + var suggestions = new List(); + + if (doc.RootElement.TryGetProperty("suggestions", out var arr) && arr.ValueKind == JsonValueKind.Array) + { + foreach (var item in arr.EnumerateArray()) + { + var title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : ""; + var reason = item.TryGetProperty("reason", out var r) ? r.GetString() ?? "" : ""; + + var priority = TaskPriority.Medium; + if (item.TryGetProperty("priority", out var p)) + { + var pStr = p.GetString() ?? ""; + priority = pStr switch + { + "High" or "high" or "高" => TaskPriority.High, + "Low" or "low" or "低" => TaskPriority.Low, + _ => TaskPriority.Medium + }; + } + + suggestions.Add(new AiBreakdownSuggestion + { + Title = title, + Priority = priority, + Reason = reason + }); + } + } + + return suggestions; + } + catch (JsonException) + { + return new List(); + } + } +} diff --git a/src/Hua.Todo.Application/Services/Voice/HybridVoiceIntentParser.cs b/src/Hua.Todo.Application/Services/Voice/HybridVoiceIntentParser.cs new file mode 100644 index 0000000..f45736c --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/HybridVoiceIntentParser.cs @@ -0,0 +1,75 @@ +using Hua.Todo.Core.Services; +using Hua.Todo.Core.Voice; +using Microsoft.Extensions.Logging; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 双策略语音意图解析器。 +/// 在线时优先使用 LLM 解析,失败或离线时降级到规则匹配。 +/// 策略切换通过配置项 Voice:Online 控制(默认 true)。 +/// +public class HybridVoiceIntentParser : IVoiceIntentParser +{ + private readonly LlmIntentParser _llmParser; + private readonly RuleIntentParser _ruleParser; + private readonly ILogger _logger; + private readonly bool _isOnline; + + /// + /// 创建 实例。 + /// + /// LLM 意图解析器(在线策略)。 + /// 规则意图解析器(离线降级策略)。 + /// 日志记录器。 + public HybridVoiceIntentParser(LlmIntentParser llmParser, RuleIntentParser ruleParser, ILogger logger) + { + _llmParser = llmParser; + _ruleParser = ruleParser; + _logger = logger; + + // 通过环境变量或配置控制在线/离线模式 + var onlineEnv = Environment.GetEnvironmentVariable("VOICE_ONLINE"); + if (!string.IsNullOrEmpty(onlineEnv)) + { + _isOnline = bool.TryParse(onlineEnv, out var val) && val; + } + else + { + // 默认在线模式(LLM 可用时) + _isOnline = true; + } + } + + /// + /// 解析语音指令:在线时优先 LLM,失败或离线时降级规则匹配。 + /// + /// STT 识别后的原始文字。 + /// 取消令牌。 + /// 解析结果。 + public async Task ParseAsync(string text, CancellationToken ct = default) + { + if (_isOnline) + { + try + { + var result = await _llmParser.ParseAsync(text, ct); + // LLM 返回有效意图(非 UNKNOWN 且置信度 >= 0.5)则直接使用 + if (result.Intent != VoiceIntent.UNKNOWN && result.Confidence >= 0.5) + { + return result; + } + + // 置信度太低,降级到规则 + } + catch (Exception ex) + { + _logger.LogWarning(ex, "LLM 意图解析失败,降级到规则匹配: {InputText}", text); + // LLM 调用失败,降级到规则匹配 + } + } + + // 降级:使用规则解析 + return await _ruleParser.ParseAsync(text, ct); + } +} diff --git a/src/Hua.Todo.Application/Services/Voice/ILlmClientService.cs b/src/Hua.Todo.Application/Services/Voice/ILlmClientService.cs new file mode 100644 index 0000000..8825a4e --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/ILlmClientService.cs @@ -0,0 +1,16 @@ +namespace Hua.Todo.Application.Services.Voice; + +/// +/// LLM HTTP 客户端服务接口。 +/// 封装外部 LLM API 调用,供意图解析器与 AI 拆分服务共用。 +/// +public interface ILlmClientService +{ + /// + /// 向 LLM 发送 prompt 并返回原始响应文本。 + /// + /// 用户 prompt 文本。 + /// 取消令牌。 + /// LLM 返回的原始文本。 + Task SendAsync(string prompt, CancellationToken ct = default); +} diff --git a/src/Hua.Todo.Application/Services/Voice/IVoiceService.cs b/src/Hua.Todo.Application/Services/Voice/IVoiceService.cs new file mode 100644 index 0000000..467ba9b --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/IVoiceService.cs @@ -0,0 +1,53 @@ +using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Voice.Models; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 语音控制 API 服务接口(Dynamic API 自动发现)。 +/// 提供语音指令解析执行、歧义确认和 AI 拆分端点。 +/// +public interface IVoiceService : IDynamicApiService +{ + /// + /// 解析并执行语音指令。 + /// POST /api/voice/command + /// + /// 语音指令请求,包含 STT 识别后的原始文字。 + /// 解析执行结果。 + [System.ComponentModel.Description("解析并执行语音指令")] + [HttpPost("command")] + Task ExecuteCommandAsync(VoiceCommandRequest request); + + /// + /// 确认歧义指令并执行。 + /// POST /api/voice/command/confirm + /// + /// 确认请求,包含意图和用户选择的任务 ID。 + /// 执行结果。 + [System.ComponentModel.Description("确认歧义语音指令并执行")] + [HttpPost("command/confirm")] + Task ConfirmCommandAsync(VoiceCommandConfirmRequest request); + + /// + /// 获取 AI 任务拆分建议。 + /// POST /api/voice/ai-breakdown + /// + /// 拆分请求,包含目标任务 ID。 + /// AI 拆分建议列表。 + [System.ComponentModel.Description("获取 AI 任务拆分建议")] + [HttpPost("ai-breakdown")] + Task GetBreakdownAsync(AiBreakdownRequest request); + + /// + /// 确认 AI 拆分建议并批量创建子任务。 + /// POST /api/voice/ai-breakdown/confirm + /// + /// 确认请求,包含父任务 ID 和选定的子任务。 + /// 创建后的子任务列表。 + [System.ComponentModel.Description("确认 AI 拆分建议并批量创建子任务")] + [HttpPost("ai-breakdown/confirm")] + Task> ConfirmBreakdownAsync(AiBreakdownConfirmRequest request); +} diff --git a/src/Hua.Todo.Application/Services/Voice/LlmClientService.cs b/src/Hua.Todo.Application/Services/Voice/LlmClientService.cs new file mode 100644 index 0000000..2ffe107 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/LlmClientService.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// LLM HTTP 客户端实现。 +/// 通过配置读取 LLM 端点与 API Key,发送 prompt 并返回原始响应文本。 +/// 支持标准 OpenAI 兼容 API 格式。 +/// +public class LlmClientService : ILlmClientService +{ + private readonly HttpClient _httpClient; + private readonly string _endpoint; + private readonly string _apiKey; + private readonly string _model; + + /// + /// 创建 实例。 + /// + /// HTTP 客户端。 + /// 应用配置。 + public LlmClientService(HttpClient httpClient, IConfiguration configuration) + { + _httpClient = httpClient; + _endpoint = configuration["Llm:Endpoint"] ?? configuration["LLM_ENDPOINT"] ?? ""; + _apiKey = configuration["Llm:ApiKey"] ?? configuration["LLM_API_KEY"] ?? ""; + _model = configuration["Llm:Model"] ?? configuration["LLM_MODEL"] ?? "gpt-4o-mini"; + + _httpClient.Timeout = TimeSpan.FromSeconds(30); + + if (!string.IsNullOrEmpty(_apiKey)) + { + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_apiKey}"); + } + } + + /// + /// 向 LLM 发送 prompt 并返回原始响应文本。 + /// + /// 用户 prompt 文本。 + /// 取消令牌。 + /// LLM 返回的原始文本。 + /// 当 LLM 端点未配置时抛出。 + /// 当 HTTP 请求失败时抛出。 + public async Task SendAsync(string prompt, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_endpoint)) + { + throw new InvalidOperationException("LLM 端点未配置。请在配置中设置 Llm:Endpoint 或 LLM_ENDPOINT 环境变量。"); + } + + var requestBody = new + { + model = _model, + messages = new[] + { + new { role = "system", content = "You are Hua.Todo voice assistant." }, + new { role = "user", content = prompt } + }, + temperature = 0.3, + max_tokens = 2000 + }; + + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(_endpoint, content, ct); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(ct); + + using var doc = JsonDocument.Parse(responseJson); + var choices = doc.RootElement.GetProperty("choices"); + var firstChoice = choices[0]; + var message = firstChoice.GetProperty("message"); + var text = message.GetProperty("content").GetString() ?? ""; + + return text.Trim(); + } +} diff --git a/src/Hua.Todo.Application/Services/Voice/LlmIntentParser.cs b/src/Hua.Todo.Application/Services/Voice/LlmIntentParser.cs new file mode 100644 index 0000000..1bc5f08 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/LlmIntentParser.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using Hua.Todo.Core.Services; +using Hua.Todo.Core.Voice; +using Microsoft.Extensions.Logging; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 基于 LLM 的意图解析器(在线策略)。 +/// 通过 system prompt 约束 LLM 输出结构化 JSON,提取意图与参数。 +/// +public class LlmIntentParser : IVoiceIntentParser +{ + private readonly ILlmClientService _llmClient; + private readonly ILogger _logger; + + /// + /// 用于约束 LLM 输出的 system prompt。 + /// 指示 LLM 解析用户自然语言指令并返回 JSON。 + /// + private const string SystemPrompt = """ + 你是 Hua.Todo 的语音指令解析器。根据用户输入,输出以下 JSON 格式: + { + "intent": "CREATE|UPDATE|DELETE|COMPLETE|UNCOMPLETE|QUERY|ADD_SUBTASK|AI_BREAKDOWN|UNKNOWN", + "params": { ... }, + "confidence": 0.0-1.0 + } + + 意图说明: + - CREATE: 创建任务,params: { title, priority? } + - UPDATE: 修改任务,params: { targetTitle, newTitle } + - DELETE: 删除任务,params: { targetTitle } + - COMPLETE: 完成任务,params: { targetTitle } + - UNCOMPLETE: 取消完成,params: { targetTitle } + - QUERY: 查询任务,params: { filter? } + - ADD_SUBTASK: 添加子任务,params: { parentTitle, subTitle } + - AI_BREAKDOWN: AI辅助拆分,params: { targetTitle } + - UNKNOWN: 无法识别 + + 只输出 JSON,不要任何解释。 + """; + + /// + /// 创建 实例。 + /// + /// LLM 客户端服务。 + /// 日志记录器。 + public LlmIntentParser(ILlmClientService llmClient, ILogger logger) + { + _llmClient = llmClient; + _logger = logger; + } + + /// + /// 使用 LLM 解析语音指令文本。 + /// + /// STT 识别后的原始文字。 + /// 取消令牌。 + /// 解析结果。解析失败时返回 UNKNOWN 意图。 + public async Task ParseAsync(string text, CancellationToken ct = default) + { + var fullPrompt = $"{SystemPrompt}\n\n用户输入:{text}"; + + try + { + var response = await _llmClient.SendAsync(fullPrompt, ct); + return ParseLlmResponse(response, text); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "LLM 意图解析调用失败: {InputText}", text); + // LLM 调用失败,返回 UNKNOWN + return new VoiceIntentResult + { + Intent = VoiceIntent.UNKNOWN, + Confidence = 0.0, + OriginalText = text + }; + } + } + + /// + /// 解析 LLM 返回的 JSON 文本为 。 + /// 对格式不正确的响应做容错处理。 + /// + /// LLM 原始响应文本。 + /// 用户原始输入文本。 + /// 解析结果。 + private VoiceIntentResult ParseLlmResponse(string llmResponse, string originalText) + { + try + { + // 清理可能的 markdown 代码块标记 + var json = llmResponse.Trim(); + if (json.StartsWith("```")) + { + var lines = json.Split('\n', StringSplitOptions.RemoveEmptyEntries); + json = string.Join("\n", lines.Skip(1).TakeWhile(l => !l.Trim().StartsWith("```"))).Trim(); + } + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var intentStr = root.TryGetProperty("intent", out var intentProp) + ? intentProp.GetString() ?? "UNKNOWN" + : "UNKNOWN"; + + var intent = Enum.TryParse(intentStr, true, out var parsed) ? parsed : VoiceIntent.UNKNOWN; + + var confidence = root.TryGetProperty("confidence", out var confProp) + ? confProp.GetDouble() + : 0.5; + + var paramsDict = new Dictionary(); + if (root.TryGetProperty("params", out var paramsProp) && paramsProp.ValueKind == JsonValueKind.Object) + { + foreach (var prop in paramsProp.EnumerateObject()) + { + var val = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString() ?? "", + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => "" + }; + paramsDict[prop.Name] = val; + } + } + + return new VoiceIntentResult + { + Intent = intent, + Params = paramsDict, + Confidence = Math.Clamp(confidence, 0.0, 1.0), + Ambiguity = false, + OriginalText = originalText + }; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "LLM 响应 JSON 解析失败: {Response}", llmResponse); + // JSON 解析失败,返回 UNKNOWN + return new VoiceIntentResult + { + Intent = VoiceIntent.UNKNOWN, + Confidence = 0.0, + OriginalText = originalText + }; + } + } +} diff --git a/src/Hua.Todo.Application/Services/Voice/Models/AiBreakdownModels.cs b/src/Hua.Todo.Application/Services/Voice/Models/AiBreakdownModels.cs new file mode 100644 index 0000000..a9a87ac --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/Models/AiBreakdownModels.cs @@ -0,0 +1,79 @@ +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.Services.Voice.Models; + +/// +/// AI 任务拆分请求 DTO。 +/// +public class AiBreakdownRequest +{ + /// + /// 要拆分的父任务 ID。 + /// + public Guid TaskId { get; set; } +} + +/// +/// AI 任务拆分响应 DTO。 +/// 包含 LLM 生成的子任务建议列表。 +/// +public class AiBreakdownResponse +{ + /// + /// LLM 生成的子任务建议列表。 + /// + public List Suggestions { get; set; } = new(); +} + +/// +/// AI 生成的单个子任务建议。 +/// +public class AiBreakdownSuggestion +{ + /// + /// 子任务标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 建议优先级。 + /// + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + + /// + /// LLM 给出的建议理由(可选)。 + /// + public string Reason { get; set; } = string.Empty; +} + +/// +/// 用户确认拆分建议后批量创建的请求 DTO。 +/// +public class AiBreakdownConfirmRequest +{ + /// + /// 父任务 ID。 + /// + public Guid ParentTaskId { get; set; } + + /// + /// 用户确认要创建的子任务列表。 + /// + public List SubTasks { get; set; } = new(); +} + +/// +/// 确认创建的子任务项。 +/// +public class SubTaskItem +{ + /// + /// 子任务标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 子任务优先级。 + /// + public TaskPriority Priority { get; set; } = TaskPriority.Medium; +} diff --git a/src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandRequest.cs b/src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandRequest.cs new file mode 100644 index 0000000..f767846 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandRequest.cs @@ -0,0 +1,34 @@ +namespace Hua.Todo.Application.Services.Voice.Models; + +/// +/// 语音指令请求 DTO。 +/// 前端将 STT 识别后的原始文字通过此模型发送到后端。 +/// +public class VoiceCommandRequest +{ + /// + /// STT 识别后的原始文字(自然语言)。 + /// + public string Text { get; set; } = string.Empty; +} + +/// +/// 用户确认歧义后的二次请求 DTO。 +/// +public class VoiceCommandConfirmRequest +{ + /// + /// 上次解析出的意图。 + /// + public string Intent { get; set; } = string.Empty; + + /// + /// 用户选择的任务 ID。 + /// + public Guid TargetId { get; set; } + + /// + /// 可选:修改后的标题(用于 UPDATE 意图)。 + /// + public string? NewTitle { get; set; } +} diff --git a/src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandResponse.cs b/src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandResponse.cs new file mode 100644 index 0000000..505cf93 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/Models/VoiceCommandResponse.cs @@ -0,0 +1,56 @@ +using Hua.Todo.Core.Voice; + +namespace Hua.Todo.Application.Services.Voice.Models; + +/// +/// 语音指令响应 DTO。 +/// 包含意图解析结果、执行结果及可能的歧义信息。 +/// +public class VoiceCommandResponse +{ + /// + /// 解析出的意图。 + /// + public VoiceIntent Intent { get; set; } + + /// + /// 提取的参数。 + /// + public Dictionary Params { get; set; } = new(); + + /// + /// 置信度(0.0 ~ 1.0)。 + /// + public double Confidence { get; set; } + + /// + /// 执行结果。 + /// + public VoiceExecutionResult Result { get; set; } = new(); +} + +/// +/// 语音指令执行结果。 +/// +public class VoiceExecutionResult +{ + /// + /// 是否执行成功。 + /// + public bool Success { get; set; } + + /// + /// 结果消息(成功时为确认信息,失败时为错误描述)。 + /// + public string Message { get; set; } = string.Empty; + + /// + /// 是否存在歧义。 + /// + public bool Ambiguity { get; set; } + + /// + /// 歧义时的候选列表。 + /// + public List Candidates { get; set; } = new(); +} diff --git a/src/Hua.Todo.Application/Services/Voice/RuleIntentParser.cs b/src/Hua.Todo.Application/Services/Voice/RuleIntentParser.cs new file mode 100644 index 0000000..31f0424 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/RuleIntentParser.cs @@ -0,0 +1,120 @@ +using System.Text.RegularExpressions; +using Hua.Todo.Core.Services; +using Hua.Todo.Core.Voice; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 基于正则表达式的规则意图解析器(离线/降级策略)。 +/// 覆盖高频 CRUD 与子任务指令,AI_BREAKDOWN 在离线模式下不可用。 +/// +public class RuleIntentParser : IVoiceIntentParser +{ + /// + /// 意图匹配规则列表。 + /// 按优先级排列,第一个匹配成功的规则将被使用。 + /// + private static readonly List<(Regex Pattern, VoiceIntent Intent, Func> ParamsExtractor)> Rules = new() + { + // UPDATE:修改/更新/编辑 xxx 改为 yyy + (new Regex(@"^(修改|更新|编辑)\s*(任务|待办)?\s*(.+?)\s*(改为|改成|改成)\s*(.+)", RegexOptions.Compiled), + VoiceIntent.UPDATE, + m => new Dictionary + { + ["targetTitle"] = m.Groups[3].Value.Trim(), + ["newTitle"] = m.Groups[5].Value.Trim() + }), + + // ADD_SUBTASK:给/为 xxx 添加子任务 yyy + (new Regex(@"^(给|为)\s*(.+?)\s*(添加|加)\s*(子任务|子项)?\s*(.+)", RegexOptions.Compiled), + VoiceIntent.ADD_SUBTASK, + m => new Dictionary + { + ["parentTitle"] = m.Groups[2].Value.Trim(), + ["subTitle"] = m.Groups[5].Value.Trim() + }), + + // AI_BREAKDOWN:帮我拆分 / AI拆分 / 智能拆分 xxx + (new Regex(@"^(帮我拆分|AI拆分|智能拆分)\s*(.+)", RegexOptions.Compiled), + VoiceIntent.AI_BREAKDOWN, + m => new Dictionary + { + ["targetTitle"] = m.Groups[2].Value.Trim() + }), + + // CREATE:创建/新增/添加/加 xxx + (new Regex(@"^(创建|新增|添加|加)\s*(任务|待办)?\s*(.+)", RegexOptions.Compiled), + VoiceIntent.CREATE, + m => new Dictionary + { + ["title"] = m.Groups[3].Value.Trim() + }), + + // DELETE:删除/删掉/移除 xxx + (new Regex(@"^(删除|删掉|移除)\s*(任务|待办)?\s*(.+)", RegexOptions.Compiled), + VoiceIntent.DELETE, + m => new Dictionary + { + ["targetTitle"] = m.Groups[3].Value.Trim() + }), + + // COMPLETE:完成/做完 xxx + (new Regex(@"^(完成|做完)\s*(任务|待办)?\s*(.+)", RegexOptions.Compiled), + VoiceIntent.COMPLETE, + m => new Dictionary + { + ["targetTitle"] = m.Groups[3].Value.Trim() + }), + + // UNCOMPLETE:取消完成/重新打开/恢复 xxx + (new Regex(@"^(取消完成|重新打开|恢复)\s*(.+)", RegexOptions.Compiled), + VoiceIntent.UNCOMPLETE, + m => new Dictionary + { + ["targetTitle"] = m.Groups[2].Value.Trim() + }), + + // QUERY:查询/查看/列出/显示 xxx + (new Regex(@"^(查询|查看|列出|显示)\s*(.+)", RegexOptions.Compiled), + VoiceIntent.QUERY, + m => new Dictionary + { + ["filter"] = m.Groups[2].Value.Trim() + }), + }; + + /// + /// 使用正则规则解析语音指令文本。 + /// + /// STT 识别后的原始文字。 + /// 取消令牌(规则解析为同步操作,忽略此参数)。 + /// 解析结果。匹配失败时返回 UNKNOWN 意图。 + public Task ParseAsync(string text, CancellationToken ct = default) + { + var trimmed = text.Trim(); + + foreach (var (pattern, intent, extractor) in Rules) + { + var match = pattern.Match(trimmed); + if (match.Success) + { + var result = new VoiceIntentResult + { + Intent = intent, + Params = extractor(match), + Confidence = 0.9, // 规则匹配默认高置信度 + Ambiguity = false, + OriginalText = trimmed + }; + return Task.FromResult(result); + } + } + + return Task.FromResult(new VoiceIntentResult + { + Intent = VoiceIntent.UNKNOWN, + Confidence = 0.0, + OriginalText = trimmed + }); + } +} diff --git a/src/Hua.Todo.Application/Services/Voice/VoiceCommandExecutor.cs b/src/Hua.Todo.Application/Services/Voice/VoiceCommandExecutor.cs new file mode 100644 index 0000000..837aa1c --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/VoiceCommandExecutor.cs @@ -0,0 +1,478 @@ +using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Interfaces; +using Hua.Todo.Application.Services.Voice.Models; +using Hua.Todo.Core.Entities; +using Hua.Todo.Core.Voice; +using Microsoft.Extensions.Logging; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 语音命令执行器。 +/// 将解析后的意图映射到 的方法调用并返回执行结果。 +/// +public class VoiceCommandExecutor +{ + private readonly ITaskService _taskService; + private readonly ILogger _logger; + + /// + /// 创建 实例。 + /// + /// 任务服务。 + /// 日志记录器。 + public VoiceCommandExecutor(ITaskService taskService, ILogger logger) + { + _taskService = taskService; + _logger = logger; + } + + /// + /// 执行解析出的语音指令。 + /// + /// 意图解析结果。 + /// 执行结果响应。 + public async Task ExecuteAsync(VoiceIntentResult intentResult) + { + var response = new VoiceCommandResponse + { + Intent = intentResult.Intent, + Params = intentResult.Params, + Confidence = intentResult.Confidence + }; + + try + { + switch (intentResult.Intent) + { + case VoiceIntent.CREATE: + return await ExecuteCreate(intentResult.Params, response); + + case VoiceIntent.UPDATE: + return await ExecuteUpdate(intentResult.Params, response); + + case VoiceIntent.DELETE: + return await ExecuteDelete(intentResult.Params, response); + + case VoiceIntent.COMPLETE: + return await ExecuteComplete(intentResult.Params, response); + + case VoiceIntent.UNCOMPLETE: + return await ExecuteUncomplete(intentResult.Params, response); + + case VoiceIntent.QUERY: + return await ExecuteQuery(intentResult.Params, response); + + case VoiceIntent.ADD_SUBTASK: + return await ExecuteAddSubTask(intentResult.Params, response); + + case VoiceIntent.UNKNOWN: + default: + response.Result = new VoiceExecutionResult + { + Success = false, + Message = "没听懂,请再说一次" + }; + return response; + } + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, "语音指令执行失败(未找到匹配任务): {Intent}", intentResult.Intent); + response.Result = new VoiceExecutionResult + { + Success = false, + Message = $"未找到匹配的任务:{ex.Message}" + }; + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "语音指令执行失败: {Intent}", intentResult.Intent); + response.Result = new VoiceExecutionResult + { + Success = false, + Message = $"执行失败:{ex.Message}" + }; + return response; + } + } + + /// + /// 确认歧义指令并执行。 + /// 用户从候选列表中选取具体目标后调用此方法。 + /// + /// 原始意图。 + /// 用户选择的任务 ID。 + /// 可选:修改后的新标题(用于 UPDATE 意图)。 + /// 执行结果。 + public async Task ExecuteConfirmedAsync(VoiceIntent intent, Guid targetId, string? newTitle = null) + { + var response = new VoiceCommandResponse { Intent = intent, Confidence = 1.0 }; + + try + { + switch (intent) + { + case VoiceIntent.COMPLETE: + { + var result = await _taskService.ToggleCompleteAsync(targetId); + // 如果任务原本是未完成,toggle 后为完成;如果已完成,需要再次 toggle + if (!result.IsCompleted) + { + result = await _taskService.ToggleCompleteAsync(targetId); + } + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已完成任务:{result.Title}" + }; + break; + } + + case VoiceIntent.DELETE: + { + var task = await _taskService.GetTaskByIdAsync(targetId); + if (task == null) + { + response.Result = new VoiceExecutionResult { Success = false, Message = $"任务 {targetId} 不存在" }; + return response; + } + await _taskService.DeleteTaskAsync(targetId); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已删除任务:{task.Title}" + }; + break; + } + + case VoiceIntent.UPDATE: + { + if (string.IsNullOrEmpty(newTitle)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "修改任务需要提供新标题" }; + return response; + } + var dto = new UpdateTaskDto { Id = targetId, Title = newTitle }; + var updated = await _taskService.UpdateTaskAsync(dto); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已将任务修改为:{updated.Title}" + }; + break; + } + + default: + response.Result = new VoiceExecutionResult { Success = false, Message = $"确认执行不支持意图 {intent}" }; + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "语音指令确认执行失败: {Intent} TargetId={TargetId}", intent, targetId); + response.Result = new VoiceExecutionResult { Success = false, Message = $"执行失败:{ex.Message}" }; + } + + return response; + } + + #region 各意图执行方法 + + private async Task ExecuteCreate(Dictionary ps, VoiceCommandResponse response) + { + var title = ps.GetValueOrDefault("title", ""); + if (string.IsNullOrEmpty(title)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "请提供任务标题" }; + return response; + } + + var priority = TaskPriority.Medium; + if (ps.TryGetValue("priority", out var pStr) && !string.IsNullOrEmpty(pStr)) + { + priority = ParsePriority(pStr); + } + + var dto = new CreateTaskDto { Title = title, Priority = priority }; + var created = await _taskService.CreateTaskAsync(dto); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已创建任务:{created.Title}" + }; + return response; + } + + private async Task ExecuteUpdate(Dictionary ps, VoiceCommandResponse response) + { + var targetTitle = ps.GetValueOrDefault("targetTitle", ""); + var newTitle = ps.GetValueOrDefault("newTitle", ""); + + if (string.IsNullOrEmpty(targetTitle) || string.IsNullOrEmpty(newTitle)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "请提供目标任务标题和新标题" }; + return response; + } + + var matches = await FindTasksByTitleAsync(targetTitle); + if (matches.Count == 0) + { + response.Result = new VoiceExecutionResult { Success = false, Message = $"未找到匹配的任务:{targetTitle}" }; + return response; + } + + if (matches.Count > 1) + { + return AmbiguityResponse(response, VoiceIntent.UPDATE, matches); + } + + var dto = new UpdateTaskDto { Id = matches[0].Id, Title = newTitle }; + var updated = await _taskService.UpdateTaskAsync(dto); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已将任务修改为:{updated.Title}" + }; + return response; + } + + private async Task ExecuteDelete(Dictionary ps, VoiceCommandResponse response) + { + var targetTitle = ps.GetValueOrDefault("targetTitle", ""); + if (string.IsNullOrEmpty(targetTitle)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "请提供要删除的任务标题" }; + return response; + } + + var matches = await FindTasksByTitleAsync(targetTitle); + if (matches.Count == 0) + { + response.Result = new VoiceExecutionResult { Success = false, Message = $"未找到匹配的任务:{targetTitle}" }; + return response; + } + + if (matches.Count > 1) + { + return AmbiguityResponse(response, VoiceIntent.DELETE, matches); + } + + await _taskService.DeleteTaskAsync(matches[0].Id); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已删除任务:{targetTitle}" + }; + return response; + } + + private async Task ExecuteComplete(Dictionary ps, VoiceCommandResponse response) + { + var targetTitle = ps.GetValueOrDefault("targetTitle", ""); + if (string.IsNullOrEmpty(targetTitle)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "请提供要完成的任务标题" }; + return response; + } + + var matches = await FindUncompletedTasksByTitleAsync(targetTitle); + if (matches.Count == 0) + { + response.Result = new VoiceExecutionResult { Success = false, Message = $"未找到匹配的未完成任务:{targetTitle}" }; + return response; + } + + if (matches.Count > 1) + { + return AmbiguityResponse(response, VoiceIntent.COMPLETE, matches); + } + + var result = await _taskService.ToggleCompleteAsync(matches[0].Id); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已完成任务:{result.Title}" + }; + return response; + } + + private async Task ExecuteUncomplete(Dictionary ps, VoiceCommandResponse response) + { + var targetTitle = ps.GetValueOrDefault("targetTitle", ""); + if (string.IsNullOrEmpty(targetTitle)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "请提供要取消完成的任务标题" }; + return response; + } + + var matches = await FindCompletedTasksByTitleAsync(targetTitle); + if (matches.Count == 0) + { + response.Result = new VoiceExecutionResult { Success = false, Message = $"未找到匹配的已完成任务:{targetTitle}" }; + return response; + } + + if (matches.Count > 1) + { + return AmbiguityResponse(response, VoiceIntent.UNCOMPLETE, matches); + } + + var result = await _taskService.ToggleCompleteAsync(matches[0].Id); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已取消完成:{result.Title}" + }; + return response; + } + + private async Task ExecuteQuery(Dictionary ps, VoiceCommandResponse response) + { + var filter = ps.GetValueOrDefault("filter", ""); + List results; + + if (filter.Contains("未完成") || filter.Contains("活跃")) + { + results = await _taskService.GetActiveTasksAsync(); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"找到 {results.Count} 个未完成任务" + }; + } + else if (filter.Contains("已完成") || filter.Contains("完成")) + { + results = await _taskService.GetCompletedTasksAsync(); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"找到 {results.Count} 个已完成任务" + }; + } + else if (filter.Contains("高优先级") || filter.Contains("紧急")) + { + results = (await _taskService.GetAllTasksAsync()).Where(t => t.Priority == TaskPriority.High).ToList(); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"找到 {results.Count} 个高优先级任务" + }; + } + else + { + results = await _taskService.GetAllTasksAsync(); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"共 {results.Count} 个任务" + }; + } + + return response; + } + + private async Task ExecuteAddSubTask(Dictionary ps, VoiceCommandResponse response) + { + var parentTitle = ps.GetValueOrDefault("parentTitle", ""); + var subTitle = ps.GetValueOrDefault("subTitle", ""); + + if (string.IsNullOrEmpty(parentTitle) || string.IsNullOrEmpty(subTitle)) + { + response.Result = new VoiceExecutionResult { Success = false, Message = "请提供父任务标题和子任务标题" }; + return response; + } + + var matches = await FindTasksByTitleAsync(parentTitle); + if (matches.Count == 0) + { + response.Result = new VoiceExecutionResult { Success = false, Message = $"未找到匹配的父任务:{parentTitle}" }; + return response; + } + + if (matches.Count > 1) + { + return AmbiguityResponse(response, VoiceIntent.ADD_SUBTASK, matches); + } + + var dto = new CreateTaskDto { Title = subTitle, Priority = TaskPriority.Medium, ParentTaskId = matches[0].Id }; + var created = await _taskService.CreateTaskAsync(dto); + response.Result = new VoiceExecutionResult + { + Success = true, + Message = $"已为任务「{parentTitle}」添加子任务:{created.Title}" + }; + return response; + } + + #endregion + + #region 辅助方法 + + /// + /// 按标题模糊查找任务(先精确匹配,再包含匹配)。 + /// + private async Task> FindTasksByTitleAsync(string title) + { + var all = await _taskService.GetAllTasksAsync(); + + // 收集所有匹配(精确 + 包含),去重后返回全部候选项以供歧义检测 + var exact = all.Where(t => t.Title.Equals(title, StringComparison.OrdinalIgnoreCase)).ToList(); + var contains = all.Where(t => t.Title.Contains(title, StringComparison.OrdinalIgnoreCase)).ToList(); + + return exact.UnionBy(contains, t => t.Id).ToList(); + } + + /// + /// 查找未完成的任务(按标题匹配)。 + /// + private async Task> FindUncompletedTasksByTitleAsync(string title) + { + var all = await _taskService.GetActiveTasksAsync(); + var exact = all.Where(t => t.Title.Equals(title, StringComparison.OrdinalIgnoreCase)).ToList(); + var contains = all.Where(t => t.Title.Contains(title, StringComparison.OrdinalIgnoreCase)).ToList(); + return exact.UnionBy(contains, t => t.Id).ToList(); + } + + /// + /// 查找已完成的任务(按标题匹配)。 + /// + private async Task> FindCompletedTasksByTitleAsync(string title) + { + var all = await _taskService.GetCompletedTasksAsync(); + var exact = all.Where(t => t.Title.Equals(title, StringComparison.OrdinalIgnoreCase)).ToList(); + var contains = all.Where(t => t.Title.Contains(title, StringComparison.OrdinalIgnoreCase)).ToList(); + return exact.UnionBy(contains, t => t.Id).ToList(); + } + + /// + /// 生成歧义响应,包含候选列表供用户选择。 + /// + private static VoiceCommandResponse AmbiguityResponse(VoiceCommandResponse response, VoiceIntent intent, List matches) + { + response.Result = new VoiceExecutionResult + { + Success = false, + Message = "找到多个匹配的任务,请确认", + Ambiguity = true, + Candidates = matches.Select(m => new VoiceCandidate { Id = m.Id, Title = m.Title }).ToList() + }; + return response; + } + + /// + /// 将中文优先级描述转换为枚举。 + /// + private static TaskPriority ParsePriority(string text) + { + return text switch + { + "高" or "High" or "high" or "紧急" => TaskPriority.High, + "低" or "Low" or "low" => TaskPriority.Low, + _ => TaskPriority.Medium + }; + } + + #endregion +} diff --git a/src/Hua.Todo.Application/Services/Voice/VoiceService.cs b/src/Hua.Todo.Application/Services/Voice/VoiceService.cs new file mode 100644 index 0000000..d2470bb --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/VoiceService.cs @@ -0,0 +1,134 @@ +using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Voice.Models; +using Hua.Todo.Core.Services; +using Hua.Todo.Core.Voice; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 语音控制 API 服务实现。 +/// 作为 Dynamic API 自动发现的端点,统一处理语音指令的解析与执行。 +/// +public class VoiceService : IVoiceService +{ + private readonly IVoiceIntentParser _intentParser; + private readonly VoiceCommandExecutor _executor; + private readonly AiBreakdownService _breakdownService; + + /// + /// 创建 实例。 + /// + /// 意图解析器(混合策略)。 + /// 命令执行器。 + /// AI 拆分服务。 + public VoiceService(IVoiceIntentParser intentParser, VoiceCommandExecutor executor, AiBreakdownService breakdownService) + { + _intentParser = intentParser; + _executor = executor; + _breakdownService = breakdownService; + } + + /// + /// 解析并执行语音指令。 + /// 对置信度 >= 0.8 且无歧义的指令自动执行; + /// 置信度 0.5-0.8 或存在歧义时返回确认信息。 + /// + public async Task ExecuteCommandAsync(VoiceCommandRequest request) + { + // 步骤 1:解析意图 + var intentResult = await _intentParser.ParseAsync(request.Text); + + // 步骤 2:UNKNOWN → 直接返回 + if (intentResult.Intent == VoiceIntent.UNKNOWN) + { + return new VoiceCommandResponse + { + Intent = VoiceIntent.UNKNOWN, + Confidence = intentResult.Confidence, + Result = new VoiceExecutionResult { Success = false, Message = "没听懂,请再说一次" } + }; + } + + // 步骤 3:AI_BREAKDOWN → 由 AI 拆分端点处理,这里返回提示 + if (intentResult.Intent == VoiceIntent.AI_BREAKDOWN) + { + return new VoiceCommandResponse + { + Intent = VoiceIntent.AI_BREAKDOWN, + Params = intentResult.Params, + Confidence = intentResult.Confidence, + Result = new VoiceExecutionResult + { + Success = true, + Message = "AI 拆分功能请通过 /api/voice/ai-breakdown 端点使用" + } + }; + } + + // 步骤 4:置信度 < 0.5 → 按 UNKNOWN 处理 + if (intentResult.Confidence < 0.5) + { + return new VoiceCommandResponse + { + Intent = intentResult.Intent, + Params = intentResult.Params, + Confidence = intentResult.Confidence, + Result = new VoiceExecutionResult { Success = false, Message = "没听懂,请再说一次" } + }; + } + + // 步骤 5:置信度 0.5-0.8 → 要求用户确认 + if (intentResult.Confidence < 0.8) + { + return new VoiceCommandResponse + { + Intent = intentResult.Intent, + Params = intentResult.Params, + Confidence = intentResult.Confidence, + Result = new VoiceExecutionResult + { + Success = false, + Message = $"您是要{GetIntentChineseName(intentResult.Intent)}吗?请确认", + Ambiguity = true + } + }; + } + + // 步骤 6:执行 + return await _executor.ExecuteAsync(intentResult); + } + + /// + public async Task ConfirmCommandAsync(VoiceCommandConfirmRequest request) + { + var intent = Enum.Parse(request.Intent, ignoreCase: true); + return await _executor.ExecuteConfirmedAsync(intent, request.TargetId, request.NewTitle); + } + + /// + public async Task GetBreakdownAsync(AiBreakdownRequest request) + { + return await _breakdownService.GetBreakdownAsync(request); + } + + /// + public async Task> ConfirmBreakdownAsync(AiBreakdownConfirmRequest request) + { + return await _breakdownService.ConfirmBreakdownAsync(request); + } + + /// + /// 获取意图的中文名称(用于确认提示)。 + /// + private static string GetIntentChineseName(VoiceIntent intent) => intent switch + { + VoiceIntent.CREATE => "创建任务", + VoiceIntent.UPDATE => "修改任务", + VoiceIntent.DELETE => "删除任务", + VoiceIntent.COMPLETE => "完成任务", + VoiceIntent.UNCOMPLETE => "取消完成", + VoiceIntent.QUERY => "查询任务", + VoiceIntent.ADD_SUBTASK => "添加子任务", + _ => intent.ToString() + }; +} diff --git a/src/Hua.Todo.Application/Services/Voice/VoiceServiceCollectionExtensions.cs b/src/Hua.Todo.Application/Services/Voice/VoiceServiceCollectionExtensions.cs new file mode 100644 index 0000000..7056544 --- /dev/null +++ b/src/Hua.Todo.Application/Services/Voice/VoiceServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +using Hua.Todo.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Hua.Todo.Application.Services.Voice; + +/// +/// 语音服务 DI 注册扩展。 +/// 注册 LLM 客户端、意图解析器、命令执行器、AI 拆分服务和语音 API。 +/// +public static class VoiceServiceCollectionExtensions +{ + /// + /// 注册所有语音相关服务到 DI 容器。 + /// + /// 服务集合。 + /// 服务集合。 + public static IServiceCollection AddVoiceServices(this IServiceCollection services) + { + // 注册 HttpClient 供 LLM 客户端使用 + services.AddHttpClient(); + services.AddSingleton(); + + // 意图解析器(单例,无状态) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 命令执行器(Scoped,依赖 ITaskService) + services.AddScoped(); + + // AI 拆分服务(Scoped,依赖 ITaskService) + services.AddScoped(); + + // 语音 API 端点(Scoped,Dynamic API 自动发现) + services.AddScoped(); + + return services; + } +} diff --git a/src/Hua.Todo.Avalonia/App.axaml.cs b/src/Hua.Todo.Avalonia/App.axaml.cs index a72b269..0a116b8 100644 --- a/src/Hua.Todo.Avalonia/App.axaml.cs +++ b/src/Hua.Todo.Avalonia/App.axaml.cs @@ -65,7 +65,7 @@ public partial class App : global::Avalonia.Application } catch (Exception ex) { - Console.WriteLine($"[App] Failed to load settings: {ex.Message}"); + Log.Error(ex, "[App] Failed to load settings"); } _appSettings ??= new AppSettings(); @@ -77,6 +77,10 @@ public partial class App : global::Avalonia.Application { if (string.IsNullOrWhiteSpace(appSettings.WebServer.ConnectionString)) { + // 开发环境使用项目根目录(方便调试);发布后使用 AppData +#if DEBUG + var dbPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "Hua.Todo.db")); +#else var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var dbDir = Path.Combine(localAppData, "Hua.Todo"); if (!Directory.Exists(dbDir)) @@ -85,6 +89,7 @@ public partial class App : global::Avalonia.Application } var dbPath = Path.Combine(dbDir, "Hua.Todo.db"); +#endif appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared"; } } @@ -97,6 +102,16 @@ public partial class App : global::Avalonia.Application { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + // 配置 Serilog 文件日志(Avalonia 桌面端) + // 开发环境统一到项目根目录;发布后使用 AppData +#if DEBUG + var logDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "logs")); +#else + var logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Hua.Todo", "logs"); +#endif + Log.Logger = LoggingConfiguration.Create(logDir).CreateLogger(); _webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!); _ = Task.Run(async () => { diff --git a/src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj b/src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj index 2c448aa..4abdf77 100644 --- a/src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj +++ b/src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj @@ -71,6 +71,7 @@ + diff --git a/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs b/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs index bae0d2b..3ec6d99 100644 --- a/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs +++ b/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs @@ -10,11 +10,12 @@ using System; using System.IO; using System.Text.Json; using System.Threading.Tasks; -using Hua.Todo.Application; -using Hua.Todo.Application.CloudSync; -using Hua.Todo.Application.Data; -using Hua.Todo.Application.DynamicApi; +using Hua.Todo.Application.Common; +using Hua.Todo.Application.Repositories; +using Hua.Todo.HttpApi; +using Hua.Todo.HttpApi.AspNetCore.DynamicApi; using Hua.Todo.Avalonia.Models; +using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; namespace Hua.Todo.Avalonia.Services; @@ -58,7 +59,7 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService // 配置控制器和 JSON 选项 builder.Services.AddControllers() - .AddApplicationPart(typeof(Hua.Todo.Application.ServiceCollectionExtensions).Assembly) + .AddApplicationPart(typeof(Hua.Todo.Application.Common.ServiceCollectionExtensions).Assembly) .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; @@ -72,10 +73,6 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService // 注册云同步反向代理(URL 管理 + 转发用 HttpClient) builder.Services.AddCloudSyncProxy(); - // 注册 URL 持久化 - builder.Services.AddSingleton(new CloudSyncProxySettingsPersistence( - Path.Combine(AppContext.BaseDirectory, "appsettings.json"))); - // 配置跨域策略 builder.Services.AddCors(options => { @@ -89,11 +86,12 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService var app = builder.Build(); - // 从 appsettings 初始化 CloudSyncUrl - var proxySettings = app.Services.GetRequiredService(); + // 从 appsettings 初始化持久化路径和 CloudSyncUrl + var proxyService = app.Services.GetRequiredService(); + proxyService.PersistencePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl)) { - proxySettings.SetUrl(_appSettings.WebServer.CloudSyncUrl); + proxyService.CloudSyncUrl = _appSettings.WebServer.CloudSyncUrl; } InitializeDatabase(app); @@ -109,7 +107,6 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService app.UseAuthorization(); app.UseDynamicApi(); app.MapControllers(); - app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings _webApp = app; @@ -144,12 +141,26 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService try { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Database.EnsureCreated(); + // 删除损坏的数据库文件并重新创建 + var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString); + var dbPath = sqliteBuilder.DataSource; + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + File.Delete(dbPath); + var walPath = dbPath + "-wal"; + var shmPath = dbPath + "-shm"; + if (File.Exists(walPath)) File.Delete(walPath); + if (File.Exists(shmPath)) File.Delete(shmPath); + } + + using var newScope = app.Services.CreateScope(); + var newContext = newScope.ServiceProvider.GetRequiredService(); + newContext.Database.EnsureCreated(); + Console.WriteLine("[EmbeddedWebServer] Database recreated successfully via EnsureCreated"); } catch (Exception ensureEx) { - Console.WriteLine($"[EmbeddedWebServer] Database ensure-created failed: {ensureEx.Message}"); + Console.WriteLine($"[EmbeddedWebServer] Database recreation failed: {ensureEx.Message}"); } } } diff --git a/src/Hua.Todo.Avalonia/Services/Platforms/AvaloniaAttachmentOpener.cs b/src/Hua.Todo.Avalonia/Services/Platforms/AvaloniaAttachmentOpener.cs new file mode 100644 index 0000000..4be37aa --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/Platforms/AvaloniaAttachmentOpener.cs @@ -0,0 +1,54 @@ +using Hua.Todo.Core.Services; +using System.Diagnostics; + +namespace Hua.Todo.Avalonia.Services.Platforms; + +/// +/// Linux Avalonia 平台文件/链接打开实现。 +/// 优先使用 xdg-open(Linux 标准方式),回退到 Process.Start。 +/// +public class AvaloniaAttachmentOpener : IPlatformAttachmentOpener +{ + /// + public Task OpenAsync(string path) + { + // 判断是否为 URL:如果以 http:// 或 https:// 开头,直接使用 Process.Start + if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = true + }); + } + else + { + // 对于本地文件/可执行文件,使用 xdg-open(Linux 标准方式) + var psi = new ProcessStartInfo + { + FileName = "xdg-open", + Arguments = $"\"{path}\"", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + + try + { + Process.Start(psi); + } + catch + { + // xdg-open 不可用时回退到直接启动 + Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = true + }); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Hua.Todo.Core/Entities/AttachmentEntity.cs b/src/Hua.Todo.Core/Entities/AttachmentEntity.cs new file mode 100644 index 0000000..4bf652b --- /dev/null +++ b/src/Hua.Todo.Core/Entities/AttachmentEntity.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; + +namespace Hua.Todo.Core.Entities; + +/// +/// 附件实体,表示待办项关联的文件或外部链接。 +/// +public class AttachmentEntity +{ + /// + /// 附件唯一标识符。 + /// + public Guid Id { get; set; } + + /// + /// 所属待办项 ID。 + /// + public Guid TaskId { get; set; } + + /// + /// 显示名称(用户可见的文件名或链接标题),最大 256 字符。 + /// + [MaxLength(256)] + public string FileName { get; set; } = string.Empty; + + /// + /// 存储路径(本地文件的绝对路径)或外部 URL,最大 1024 字符。 + /// + [MaxLength(1024)] + public string FilePath { get; set; } = string.Empty; + + /// + /// 文件大小(字节),外部链接为 0。 + /// + public long FileSize { get; set; } + + /// + /// MIME 类型(如 text/plain、application/pdf),外部链接为空字符串,最大 128 字符。 + /// + [MaxLength(128)] + public string ContentType { get; set; } = string.Empty; + + /// + /// 附件类型。 + /// + public AttachmentType AttachmentType { get; set; } = AttachmentType.LocalFile; + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// 所属待办项导航属性。 + /// + public TaskEntity Task { get; set; } = null!; +} diff --git a/src/Hua.Todo.Core/Entities/AttachmentType.cs b/src/Hua.Todo.Core/Entities/AttachmentType.cs new file mode 100644 index 0000000..bdc9ead --- /dev/null +++ b/src/Hua.Todo.Core/Entities/AttachmentType.cs @@ -0,0 +1,17 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 附件类型枚举。 +/// +public enum AttachmentType +{ + /// + /// 本地文件(已复制到应用数据目录)。 + /// + LocalFile = 0, + + /// + /// 外部链接(URL)。 + /// + ExternalLink = 1 +} diff --git a/src/Hua.Todo.Core/Entities/TaskEntity.cs b/src/Hua.Todo.Core/Entities/TaskEntity.cs index 998e4e4..b014e73 100644 --- a/src/Hua.Todo.Core/Entities/TaskEntity.cs +++ b/src/Hua.Todo.Core/Entities/TaskEntity.cs @@ -1,52 +1,85 @@ +using System.ComponentModel.DataAnnotations; + namespace Hua.Todo.Core.Entities; /// -/// 任务实体类,表示一个代办 +/// 任务实体类,表示一个 Todo 待办项。 +/// 继承 以获得 ABP 风格的审计字段。 /// -public class TaskEntity +public class TaskEntity : FullAuditedEntityWithUser { /// - /// 任务唯一标识符 + /// 任务所属用户 ID(云端隔离),本地模式为 。 /// - public int Id { get; set; } - + public Guid UserId { get; set; } = TodoUserIds.LocalUserId; + /// - /// 任务标题 + /// 任务所属用户导航属性。 + /// + public UserEntity? User { get; set; } + + /// + /// 任务标题。 /// public string Title { get; set; } = string.Empty; - + /// - /// 任务优先级 + /// 任务优先级。 /// public TaskPriority Priority { get; set; } = TaskPriority.Medium; - + /// - /// 任务是否已完成 + /// 任务编号(用户级自增字符串,用于展示)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 任务是否已完成。 /// public bool IsCompleted { get; set; } - + /// - /// 任务创建时间(UTC) + /// 父任务 ID(外键),用于支持子任务功能。 + /// 类型为 ,与主键类型一致。 /// - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - + public Guid? ParentTaskId { get; set; } + /// - /// 任务最后更新时间(UTC) - /// - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - /// - /// 父任务ID,用于支持子任务功能 - /// - public int? ParentTaskId { get; set; } - - /// - /// 父任务导航属性 + /// 父任务导航属性。 /// public TaskEntity? ParentTask { get; set; } - + /// - /// 子任务集合 + /// 子任务集合。 /// public List SubTasks { get; set; } = new(); + + // ========== v1.3.0 新增字段 ========== + + /// + /// 待办项类型(Normal = 普通,Meeting = 会议)。 + /// + public TaskType TaskType { get; set; } = TaskType.Normal; + + /// + /// 会议纪要/转写文字(仅 Meeting 类型有值),最大 20000 字符。 + /// + [MaxLength(20000)] + public string? MeetingNotes { get; set; } + + /// + /// 录音时长(秒),仅 Meeting 类型有值。 + /// + public double? AudioDuration { get; set; } + + /// + /// 多行描述文本,最大 5000 字符,用于记录待办项相关的详细说明、步骤或备注。 + /// + [MaxLength(5000)] + public string? Description { get; set; } + + /// + /// 附件列表。 + /// + public List Attachments { get; set; } = new(); } diff --git a/src/Hua.Todo.Core/Entities/TaskType.cs b/src/Hua.Todo.Core/Entities/TaskType.cs new file mode 100644 index 0000000..2ba42e6 --- /dev/null +++ b/src/Hua.Todo.Core/Entities/TaskType.cs @@ -0,0 +1,17 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 待办项类型,区分普通待办项与会议。 +/// +public enum TaskType +{ + /// + /// 普通待办项(默认)。 + /// + Normal = 0, + + /// + /// 会议:包含录音/纪要,支持 AI 任务拆分。 + /// + Meeting = 1 +} diff --git a/src/Hua.Todo.Core/Interfaces/IAttachmentRepository.cs b/src/Hua.Todo.Core/Interfaces/IAttachmentRepository.cs new file mode 100644 index 0000000..9ac5f9c --- /dev/null +++ b/src/Hua.Todo.Core/Interfaces/IAttachmentRepository.cs @@ -0,0 +1,43 @@ +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Core.Interfaces; + +/// +/// 附件仓储接口,定义附件数据访问操作。 +/// +public interface IAttachmentRepository +{ + /// + /// 根据待办项 ID 获取所有附件。 + /// + /// 待办项 ID。 + /// 附件列表。 + System.Threading.Tasks.Task> GetByTaskIdAsync(Guid taskId); + + /// + /// 根据附件 ID 获取附件。 + /// + /// 附件 ID。 + /// 附件实体,不存在则返回 null。 + System.Threading.Tasks.Task GetByIdAsync(Guid id); + + /// + /// 添加新附件。 + /// + /// 附件实体。 + /// 已持久化的附件实体(含生成的 ID)。 + System.Threading.Tasks.Task AddAsync(AttachmentEntity attachment); + + /// + /// 删除附件。 + /// + /// 附件 ID。 + System.Threading.Tasks.Task DeleteAsync(Guid id); + + /// + /// 获取指定待办项的附件数量。 + /// + /// 待办项 ID。 + /// 附件数量。 + System.Threading.Tasks.Task GetCountByTaskIdAsync(Guid taskId); +} diff --git a/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs b/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs index 7dd1257..10ff56d 100644 --- a/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs +++ b/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs @@ -18,7 +18,7 @@ public interface ITaskRepository /// /// 任务唯一标识符 /// 任务实体对象,如果不存在则返回 null 的任务 - System.Threading.Tasks.Task GetByIdAsync(int id); + System.Threading.Tasks.Task GetByIdAsync(Guid id); /// /// 获取所有未完成的任务 @@ -51,12 +51,19 @@ public interface ITaskRepository /// /// 要删除的任务唯一标识符 /// 表示删除操作的任务 - System.Threading.Tasks.Task DeleteAsync(int id); + System.Threading.Tasks.Task DeleteAsync(Guid id); /// /// 获取指定父任务的所有子任务 /// /// 父任务唯一标识符 - /// 子任务实体的列表任务 - System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId); + /// 子任务实体的列表 + System.Threading.Tasks.Task> GetSubTasksAsync(Guid parentTaskId); + + /// + /// 获取指定用户的任务中最大的数字型 Code。 + /// + /// 用户 ID。 + /// 最大数字 Code;若无任务或无可解析 Code 则返回 0。 + System.Threading.Tasks.Task GetMaxCodeAsync(Guid userId); } diff --git a/src/Hua.Todo.Core/Services/IPlatformAttachmentOpener.cs b/src/Hua.Todo.Core/Services/IPlatformAttachmentOpener.cs new file mode 100644 index 0000000..b8dd17a --- /dev/null +++ b/src/Hua.Todo.Core/Services/IPlatformAttachmentOpener.cs @@ -0,0 +1,15 @@ +namespace Hua.Todo.Core.Services; + +/// +/// 平台文件/链接打开接口。 +/// 由各宿主(MAUI / Avalonia)注册平台特定实现。 +/// +public interface IPlatformAttachmentOpener +{ + /// + /// 使用系统关联程序打开文件路径或外部 URL。 + /// + /// 文件路径或 URL。 + /// 表示打开操作的任务。 + Task OpenAsync(string path); +} diff --git a/src/Hua.Todo.Core/Services/IVoiceInputService.cs b/src/Hua.Todo.Core/Services/IVoiceInputService.cs new file mode 100644 index 0000000..0c112f2 --- /dev/null +++ b/src/Hua.Todo.Core/Services/IVoiceInputService.cs @@ -0,0 +1,25 @@ +namespace Hua.Todo.Core.Services; + +/// +/// 语音输入服务接口(STT),各平台通过原生 API 实现。 +/// 识别结果通过回调返回给调用方。 +/// +public interface IVoiceInputService +{ + /// + /// 是否正在监听语音输入。 + /// + bool IsListening { get; } + + /// + /// 开始语音识别,识别结果通过 回调返回。 + /// + /// 识别结果回调,参数为识别出的文字。 + /// 识别错误回调(可选),参数为错误信息。 + Task StartListeningAsync(Action onResult, Action? onError = null); + + /// + /// 停止语音识别。 + /// + Task StopListeningAsync(); +} diff --git a/src/Hua.Todo.Core/Services/IVoiceIntentParser.cs b/src/Hua.Todo.Core/Services/IVoiceIntentParser.cs new file mode 100644 index 0000000..948504e --- /dev/null +++ b/src/Hua.Todo.Core/Services/IVoiceIntentParser.cs @@ -0,0 +1,18 @@ +using Hua.Todo.Core.Voice; + +namespace Hua.Todo.Core.Services; + +/// +/// 语音意图解析器接口。 +/// 将自然语言文本解析为结构化的意图与参数,供命令执行器调用。 +/// +public interface IVoiceIntentParser +{ + /// + /// 解析语音指令文本,返回意图与参数。 + /// + /// STT 识别后的原始文字。 + /// 取消令牌。 + /// 解析结果,包含意图、参数、置信度等信息。 + Task ParseAsync(string text, CancellationToken ct = default); +} diff --git a/src/Hua.Todo.Core/Services/IVoiceOutputService.cs b/src/Hua.Todo.Core/Services/IVoiceOutputService.cs new file mode 100644 index 0000000..94c0c40 --- /dev/null +++ b/src/Hua.Todo.Core/Services/IVoiceOutputService.cs @@ -0,0 +1,24 @@ +namespace Hua.Todo.Core.Services; + +/// +/// 语音输出服务接口(TTS),各平台通过原生 API 实现。 +/// 用于将执行结果以语音方式反馈给用户。 +/// +public interface IVoiceOutputService +{ + /// + /// 是否正在播报语音。 + /// + bool IsSpeaking { get; } + + /// + /// 播报指定文本(TTS)。 + /// + /// 要播报的文本内容。 + Task SpeakAsync(string text); + + /// + /// 停止当前语音播报。 + /// + Task StopSpeakingAsync(); +} diff --git a/src/Hua.Todo.Core/Voice/VoiceIntent.cs b/src/Hua.Todo.Core/Voice/VoiceIntent.cs new file mode 100644 index 0000000..55fb9a2 --- /dev/null +++ b/src/Hua.Todo.Core/Voice/VoiceIntent.cs @@ -0,0 +1,35 @@ +namespace Hua.Todo.Core.Voice; + +/// +/// 语音指令意图枚举。 +/// 定义语音控制所支持的各类操作意图。 +/// +public enum VoiceIntent +{ + /// 无法识别的意图。 + UNKNOWN = 0, + + /// 创建任务:params 含 title(必填)、priority(可选:High/Medium/Low)。 + CREATE = 1, + + /// 修改任务:params 含 targetTitle(必填)、newTitle(必填)。 + UPDATE = 2, + + /// 删除任务:params 含 targetTitle(必填)。 + DELETE = 3, + + /// 完成任务:params 含 targetTitle(必填)。 + COMPLETE = 4, + + /// 取消完成:params 含 targetTitle(必填)。 + UNCOMPLETE = 5, + + /// 查询任务:params 含 filter(可选:如"未完成""高优先级")。 + QUERY = 6, + + /// 添加子任务:params 含 parentTitle(必填)、subTitle(必填)。 + ADD_SUBTASK = 7, + + /// AI 辅助拆分:params 含 targetTitle(必填)。仅在线模式可用。 + AI_BREAKDOWN = 8, +} diff --git a/src/Hua.Todo.Core/Voice/VoiceIntentResult.cs b/src/Hua.Todo.Core/Voice/VoiceIntentResult.cs new file mode 100644 index 0000000..e0c5a8b --- /dev/null +++ b/src/Hua.Todo.Core/Voice/VoiceIntentResult.cs @@ -0,0 +1,56 @@ +namespace Hua.Todo.Core.Voice; + +/// +/// 语音意图解析结果。 +/// 包含解析出的意图、提取的参数、置信度及歧义信息。 +/// +public class VoiceIntentResult +{ + /// + /// 解析出的意图类型。 + /// + public VoiceIntent Intent { get; set; } = VoiceIntent.UNKNOWN; + + /// + /// 从文本中提取的参数字典。 + /// key 为参数名(如 title、targetTitle、newTitle),value 为参数值。 + /// + public Dictionary Params { get; set; } = new(); + + /// + /// 解析置信度(0.0 ~ 1.0)。 + /// >= 0.8 直接执行;0.5 ~ 0.8 确认后执行;< 0.5 按 UNKNOWN 处理。 + /// + public double Confidence { get; set; } + + /// + /// 是否存在歧义(目标匹配到多个候选项时返回 true)。 + /// + public bool Ambiguity { get; set; } + + /// + /// 歧义时的候选列表(仅当 为 true 时有效)。 + /// + public List Candidates { get; set; } = new(); + + /// + /// 原始文本(用于调试与日志)。 + /// + public string OriginalText { get; set; } = string.Empty; +} + +/// +/// 歧义候选项,表示一个匹配到的任务备选项。 +/// +public class VoiceCandidate +{ + /// + /// 任务 ID。 + /// + public Guid Id { get; set; } + + /// + /// 任务标题。 + /// + public string Title { get; set; } = string.Empty; +} diff --git a/src/Hua.Todo.Host/Hua.Todo.Host.csproj b/src/Hua.Todo.Host/Hua.Todo.Host.csproj index cfc9fd1..a84ab2d 100644 --- a/src/Hua.Todo.Host/Hua.Todo.Host.csproj +++ b/src/Hua.Todo.Host/Hua.Todo.Host.csproj @@ -11,11 +11,17 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + - + + TargetFramework=net10.0 + + diff --git a/src/Hua.Todo.Host/Program.cs b/src/Hua.Todo.Host/Program.cs index acb2a7b..c62f75a 100644 --- a/src/Hua.Todo.Host/Program.cs +++ b/src/Hua.Todo.Host/Program.cs @@ -1,21 +1,41 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Hua.Todo.Application; -using Hua.Todo.Application.DynamicApi; -using Hua.Todo.Application.Interfaces; -using Hua.Todo.Application.Mcp; +using Serilog; +using Hua.Todo.Application.Common; using Hua.Todo.Application.Models; +using Hua.Todo.Application.Repositories; +using Hua.Todo.HttpApi; +using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; +using Hua.Todo.HttpApi.AspNetCore.DynamicApi; +using Hua.Todo.HttpApi.AspNetCore.DynamicApi.Swagger; +using Hua.Todo.HttpApi.AspNetCore.Mcp; + +// 配置 Serilog 文件日志(开发环境统一到项目根目录 logs/) +Log.Logger = LoggingConfiguration.Create( + Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "logs"))).CreateLogger(); +Log.Information("[Startup] Serilog 初始化完成"); var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSerilog(); +Log.Information("[Startup] WebApplication.CreateBuilder 完成"); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddAuthorization(); +builder.Services.AddSwaggerGen(options => +{ + options.DocumentFilter(); +}); +Log.Information("[Startup] Swagger 注册完成"); builder.Services.AddApplicationServices("Data Source=Hua.Todo.db"); +Log.Information("[Startup] AddApplicationServices 完成"); + +// 注册云同步服务端能力(认证 / RBAC / 二次认证 / 任务同步 / 安全策略) +builder.Services.AddCloudSyncServer(); +Log.Information("[Startup] AddCloudSyncServer 完成"); // 注册 MCP Server(依赖 ITaskService,须在 AddApplicationServices 之后调用) builder.Services.AddMcpServerServices(); +Log.Information("[Startup] AddMcpServerServices 完成"); builder.Services.AddCors(options => { @@ -26,14 +46,18 @@ builder.Services.AddCors(options => .AllowAnyHeader(); }); }); +Log.Information("[Startup] DI 注册全部完成"); var app = builder.Build(); +Log.Information("[Startup] builder.Build() 完成"); // Apply database migrations using (var scope = app.Services.CreateScope()) { - var dbContext = scope.ServiceProvider.GetRequiredService(); + Log.Information("[Startup] 开始 EF Core Migrate..."); + var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.Migrate(); + Log.Information("[Startup] EF Core Migrate 完成"); } if (app.Environment.IsDevelopment()) @@ -44,6 +68,7 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseCors("AllowAll"); +app.UseAuthentication(); app.UseAuthorization(); app.UseDynamicApi(); diff --git a/src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandler.cs b/src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandler.cs new file mode 100644 index 0000000..73443ea --- /dev/null +++ b/src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandler.cs @@ -0,0 +1,422 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.HttpApi.Android.DynamicApi; + +/// +/// 适用于非 ASP.NET Core 环境(如 Android TcpListener)的 Dynamic API 请求分发器。 +/// 功能与 DynamicApiMiddleware 等价:根据路由约定将 的接口方法映射为 HTTP API。 +/// 与 AspNetCore 版本的区别:不依赖 HttpContext,以原始 (method, path, body) 元组作为输入, +/// 直接返回 (statusCode, jsonBody),由外部 TcpListener 负责写入网络流。 +/// +public class DynamicApiHandler +{ + private readonly Dictionary _serviceTypeCache; + private readonly IServiceProvider _rootServiceProvider; + + /// + /// JSON 序列化选项(CamelCase + 枚举字符串转换)。 + /// + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }; + + /// + /// 创建 并扫描指定程序集中的 接口。 + /// + /// 根级 IServiceProvider,用于创建请求作用域。 + /// 需要扫描的程序集列表。 + public DynamicApiHandler(IServiceProvider rootServiceProvider, params Assembly[] scanAssemblies) + { + _rootServiceProvider = rootServiceProvider; + _serviceTypeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + BuildServiceTypeCache(scanAssemblies); + } + + /// + /// 处理 HTTP 请求并返回 JSON 响应。 + /// + /// HTTP 方法(GET/POST/PUT/DELETE/PATCH)。 + /// 请求路径(如 /api/task/3/subtasks)。 + /// 请求体内容(可为空字符串)。 + /// (HTTP 状态码, JSON 响应体)。 + public async Task<(int StatusCode, string Json)> HandleAsync(string httpMethod, string path, string body) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length < 2 || !segments[0].Equals("api", StringComparison.OrdinalIgnoreCase)) + { + return (404, NotFoundJson); + } + + var serviceName = segments[1]; + if (!_serviceTypeCache.TryGetValue(serviceName, out var serviceType)) + { + return (404, NotFoundJson); + } + + if (!IsRemoteServiceEnabled(serviceType)) + { + return (404, NotFoundJson); + } + + using var scope = _rootServiceProvider.CreateScope(); + var service = scope.ServiceProvider.GetService(serviceType); + if (service == null) + { + return (404, NotFoundJson); + } + + var method = FindMethod(serviceType, httpMethod, segments.Skip(2).ToArray()); + if (method == null || !IsRemoteServiceEnabled(method)) + { + return (404, NotFoundJson); + } + + try + { + var result = await InvokeMethod(service, method, segments, body); + return BuildResponse(result, null, method.Name); + } + catch (Exception ex) + { + return BuildResponse(null, ex, method.Name); + } + } + + private static string NotFoundJson => JsonSerializer.Serialize( + new ApiResponse(false, null, "Not Found", null), JsonOptions); + + private static (int StatusCode, string Json) BuildResponse(object? result, Exception? exception, string methodName) + { + var success = exception == null; + + var message = methodName switch + { + _ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => success ? "获取成功" : "获取失败", + _ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => success ? "创建成功" : "创建失败", + _ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => success ? "更新成功" : "更新失败", + _ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => success ? "删除成功" : "删除失败", + _ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => success ? "操作成功" : "操作失败", + _ => success ? "操作成功" : "操作失败" + }; + + List? errors = null; + if (exception != null) + { + errors = new List { exception.Message }; + } + + var response = new ApiResponse(success, result, message, errors); + var json = JsonSerializer.Serialize(response, JsonOptions); + + var statusCode = exception switch + { + KeyNotFoundException => 404, + ArgumentException => 400, + _ => 200 + }; + + return (statusCode, json); + } + + private void BuildServiceTypeCache(Assembly[] assemblies) + { + var serviceTypes = assemblies + .SelectMany(a => a.GetTypes()) + .Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t) && t != typeof(IDynamicApiService)) + .Distinct() + .ToList(); + + foreach (var type in serviceTypes) + { + var routeAttr = type.GetCustomAttribute(); + if (routeAttr != null) + { + _serviceTypeCache[routeAttr.Route] = type; + continue; + } + + var name = type.Name; + if (name.StartsWith("I", StringComparison.Ordinal) && name.Length > 1 && char.IsUpper(name[1])) + { + name = name.Substring(1); + } + if (name.EndsWith("Service", StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - "Service".Length); + } + if (name.EndsWith("App", StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - "App".Length); + } + + _serviceTypeCache[name] = type; + } + } + + private static bool IsRemoteServiceEnabled(MemberInfo member) + { + var attr = member.GetCustomAttribute(); + return attr == null || attr.IsEnabled; + } + + private static MethodInfo? FindMethod(Type serviceType, string httpMethod, string[] pathSegments) + { + var methods = serviceType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => IsRemoteServiceEnabled(m)) + .ToList(); + + // 尝试以路径首段作为方法名匹配 + if (pathSegments.Length > 0) + { + var methodName = pathSegments[0]; + + // 精确匹配方法名 + var exactMatch = methods.FirstOrDefault(m => + m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase) && + MatchesHttpMethod(m, httpMethod)); + if (exactMatch != null) return exactMatch; + + // 规范化方法名匹配(去 Get/Async 前后缀) + foreach (var method in methods) + { + if (!MatchesHttpMethod(method, httpMethod)) continue; + + var normalized = NormalizeMethodName(method.Name); + if (normalized.Equals(methodName, StringComparison.OrdinalIgnoreCase)) + return method; + + // GetSubTasksAsync → "subtasks" + if (normalized.Equals($"{methodName}Tasks", StringComparison.OrdinalIgnoreCase)) + return method; + } + } + + // 处理 /api/task/{id}/subtasks 模式(路径末段为子资源名) + if (pathSegments.Length >= 2) + { + var secondName = pathSegments[1]; + foreach (var method in methods) + { + if (!MatchesHttpMethod(method, httpMethod)) continue; + if (method.GetParameters().Length != 1) continue; + if (!IsSimpleType(method.GetParameters()[0].ParameterType)) continue; + + var normalized = NormalizeMethodName(method.Name); + if (normalized.Equals(secondName, StringComparison.OrdinalIgnoreCase)) + return method; + } + } + + // 处理 /api/task/{id}/toggle 模式(PATCH + 路径末段匹配方法名) + if (pathSegments.Length >= 2 && httpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase)) + { + var actionName = pathSegments[1]; + foreach (var method in methods) + { + if (method.GetParameters().Length != 1) continue; + if (!IsSimpleType(method.GetParameters()[0].ParameterType)) continue; + + var normalized = NormalizeMethodName(method.Name); + if (normalized.Equals(actionName, StringComparison.OrdinalIgnoreCase)) + return method; + if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) && + actionName.Equals("toggle", StringComparison.OrdinalIgnoreCase)) + return method; + } + } + + // 兜底:尝试以路径段匹配方法参数(无自定义路由时) + foreach (var method in methods) + { + if (!MatchesHttpMethod(method, httpMethod)) continue; + + var parameters = method.GetParameters(); + if (parameters.Length > 0 && pathSegments.Length > 0 && MatchesRoute(method, pathSegments)) + return method; + } + + return null; + } + + private static string NormalizeMethodName(string methodName) + { + var name = methodName; + if (name.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(3); + if (name.EndsWith("Async", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(0, name.Length - "Async".Length); + return name; + } + + private static bool MatchesHttpMethod(MethodInfo method, string httpMethod) + { + if (method.GetCustomAttribute() != null) + return httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase); + if (method.GetCustomAttribute() != null) + return httpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase); + if (method.GetCustomAttribute() != null) + return httpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase); + if (method.GetCustomAttribute() != null) + return httpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase); + if (method.GetCustomAttribute() != null) + return httpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase); + + if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase)) + return httpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase); + + var methodName = method.Name; + if (methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) return httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase); + if (methodName.StartsWith("Put", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) return httpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase); + if (methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Remove", StringComparison.OrdinalIgnoreCase)) return httpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase); + if (methodName.StartsWith("Post", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Insert", StringComparison.OrdinalIgnoreCase)) return httpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase); + if (methodName.StartsWith("Patch", StringComparison.OrdinalIgnoreCase)) return httpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase); + + return httpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesRoute(MethodInfo method, string[] pathSegments) + { + var route = GetRouteTemplate(method); + if (!string.IsNullOrEmpty(route)) + { + var routeSegments = route.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (routeSegments.Length > 0 && pathSegments.Length > 0) + return routeSegments[0].Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase); + return routeSegments.Length == 0 && pathSegments.Length == 0; + } + + if (pathSegments.Length == 0) return true; + + // 方法名匹配 + if (method.Name.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase)) + return true; + + if (NormalizeMethodName(method.Name).Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private static string? GetRouteTemplate(MethodInfo method) + { + return method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route; + } + + private static bool IsSimpleType(Type type) + { + return type.IsPrimitive || + type == typeof(string) || + type == typeof(decimal) || + type == typeof(DateTime) || + type == typeof(Guid) || + Nullable.GetUnderlyingType(type) != null; + } + + private async Task InvokeMethod(object service, MethodInfo method, string[] allSegments, string body) + { + var parameters = method.GetParameters(); + var args = new List(); + + // pathSegments = allSegments 去掉 "api" 和 serviceName 之后的剩余部分 + var pathSegments = allSegments.Skip(2).ToArray(); + + // 方法名占用的段数:如果 pathSegments[0] 匹配方法名,则参数从 pathSegments[1] 开始 + var paramStartIndex = 0; + if (pathSegments.Length > 0 && MatchesRoute(method, pathSegments)) + { + // 如果路径首段匹配了方法名(非带参数路由),跳过它 + var route = GetRouteTemplate(method); + if (string.IsNullOrEmpty(route) && pathSegments.Length > 0) + { + paramStartIndex = 1; + } + } + + foreach (var param in parameters) + { + var fromBodyAttr = param.GetCustomAttribute(); + + if (fromBodyAttr != null) + { + args.Add(DeserializeBody(body, param.ParameterType)); + } + else if (IsSimpleType(param.ParameterType)) + { + // 从路径段绑定简单类型参数 + var pathIndex = paramStartIndex + args.Count(a => a != null && !(a is string)); + if (pathIndex < pathSegments.Length) + { + args.Add(ConvertValue(pathSegments[pathIndex], param.ParameterType)); + } + else + { + args.Add(param.ParameterType.IsValueType && Nullable.GetUnderlyingType(param.ParameterType) == null + ? Activator.CreateInstance(param.ParameterType) + : null); + } + } + else + { + args.Add(DeserializeBody(body, param.ParameterType)); + } + } + + var result = method.Invoke(service, args.ToArray()); + + if (result is Task task) + { + await task; + return task.GetType().GetProperty("Result")?.GetValue(task); + } + + return result; + } + + private static object? ConvertValue(string value, Type targetType) + { + try + { + if (targetType == typeof(string)) return value; + if (targetType == typeof(Guid) || targetType == typeof(Guid?)) return Guid.Parse(value); + if (targetType == typeof(int) || targetType == typeof(int?)) return int.Parse(value); + if (targetType == typeof(long) || targetType == typeof(long?)) return long.Parse(value); + if (targetType == typeof(bool) || targetType == typeof(bool?)) return bool.Parse(value); + if (targetType == typeof(decimal) || targetType == typeof(decimal?)) return decimal.Parse(value); + if (targetType == typeof(DateTime) || targetType == typeof(DateTime?)) return DateTime.Parse(value); + return Convert.ChangeType(value, targetType); + } + catch + { + throw new ArgumentException($"Cannot convert '{value}' to {targetType.Name}"); + } + } + + private static object? DeserializeBody(string body, Type dtoType) + { + if (string.IsNullOrWhiteSpace(body)) return null; + + return JsonSerializer.Deserialize(body, dtoType, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }); + } + + private sealed record ApiResponse(bool Success, object? Data, string? Message, List? Errors); +} \ No newline at end of file diff --git a/src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandlerExtensions.cs b/src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandlerExtensions.cs new file mode 100644 index 0000000..de801ee --- /dev/null +++ b/src/Hua.Todo.HttpApi.Android/DynamicApi/DynamicApiHandlerExtensions.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Hua.Todo.HttpApi.Android.DynamicApi; + +/// +/// 的 DI 注册扩展方法。 +/// 与 AspNetCore 版 DynamicApiExtensions.UseDynamicApi() 平级,适用于非 ASP.NET Core 环境。 +/// +public static class DynamicApiHandlerExtensions +{ + /// + /// 注册 为单例并扫描指定程序集中的 。 + /// + /// 服务集合。 + /// 需要扫描的程序集。不传则默认扫描调用方程序集。 + /// 服务集合(链式调用)。 + public static IServiceCollection AddDynamicApiHandler(this IServiceCollection services, params Assembly[] scanAssemblies) + { + if (scanAssemblies.Length == 0) + { + scanAssemblies = new[] { Assembly.GetCallingAssembly() }; + } + + services.TryAddSingleton(sp => new DynamicApiHandler(sp, scanAssemblies)); + return services; + } +} diff --git a/src/Hua.Todo.HttpApi.Android/Hua.Todo.HttpApi.Android.csproj b/src/Hua.Todo.HttpApi.Android/Hua.Todo.HttpApi.Android.csproj new file mode 100644 index 0000000..6dda353 --- /dev/null +++ b/src/Hua.Todo.HttpApi.Android/Hua.Todo.HttpApi.Android.csproj @@ -0,0 +1,20 @@ + + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + enable + enable + Library + true + $(NoWarn);CS1591 + + + + + + + + + + + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs b/src/Hua.Todo.HttpApi.AspNetCore/CloudSync/Auth/SessionAuthenticationHandler.cs similarity index 96% rename from src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs rename to src/Hua.Todo.HttpApi.AspNetCore/CloudSync/Auth/SessionAuthenticationHandler.cs index d429f2b..9a1b11c 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/CloudSync/Auth/SessionAuthenticationHandler.cs @@ -1,12 +1,13 @@ using System.Security.Claims; using System.Text.Encodings.Web; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Auth; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Hua.Todo.Application.Data; -namespace Hua.Todo.Application.CloudSync.Auth; +namespace Hua.Todo.HttpApi.AspNetCore.CloudSync.Auth; /// /// 基于服务端会话表的 Bearer Token 鉴权处理器。 @@ -106,4 +107,4 @@ public class SessionAuthenticationHandler : AuthenticationHandler +/// 云同步服务端配置(静态 DI 注册)。 +/// +public static class CloudSyncServerService +{ + /// + /// 注册云同步服务端能力:认证 / RBAC / 任务同步 / 安全策略 / 管理后台。 + /// + public static IServiceCollection AddCloudSyncServer(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.AddScoped(); + + services.AddSingleton(); + services.AddScoped, PasswordHasher>(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddHttpClient("ProbeClient", client => + { + client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddAuthentication(SessionAuthenticationDefaults.Scheme) + .AddScheme(SessionAuthenticationDefaults.Scheme, _ => { }); + + services.AddAuthorization(options => + { + options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead)); + options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite)); + options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite)); + options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead)); + options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite)); + options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage)); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/DynamicApi/DynamicApiExtensions.cs b/src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiExtensions.cs similarity index 90% rename from src/Hua.Todo.Application/DynamicApi/DynamicApiExtensions.cs rename to src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiExtensions.cs index 40cbc90..315a880 100644 --- a/src/Hua.Todo.Application/DynamicApi/DynamicApiExtensions.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Builder; -namespace Hua.Todo.Application.DynamicApi; +namespace Hua.Todo.HttpApi.AspNetCore.DynamicApi; /// /// Dynamic API 中间件扩展方法。 diff --git a/src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs b/src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiMiddleware.cs similarity index 84% rename from src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs rename to src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiMiddleware.cs index 3adf2b3..521609e 100644 --- a/src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiMiddleware.cs @@ -1,10 +1,18 @@ using System.Reflection; +using System.Text.Encodings.Web; using System.Text.Json; +using Hua.Todo.Application.Services.CloudSync.Auth; +using Hua.Todo.Application.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Hua.Todo.Application.Interfaces; +using Microsoft.Extensions.Logging; +using Hua.Todo.HttpApi; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; +using AllowAnonymousAttribute = Hua.Todo.HttpApi.Attributes.AllowAnonymousAttribute; -namespace Hua.Todo.Application.DynamicApi; +namespace Hua.Todo.HttpApi.AspNetCore.DynamicApi; /// /// Dynamic API 中间件。 @@ -14,6 +22,7 @@ public class DynamicApiMiddleware { private readonly RequestDelegate _next; private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; private static readonly Dictionary _serviceTypeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); static DynamicApiMiddleware() @@ -23,13 +32,28 @@ public class DynamicApiMiddleware private static void InitializeServiceTypeCache() { - var assembly = typeof(IDynamicApiService).Assembly; - var serviceTypes = assembly.GetTypes() + // 扫描 Application 程序集(ITaskService 等基础服务)和本程序集(CloudSync 服务) + var assemblies = new[] + { + typeof(ITaskService).Assembly, + typeof(DynamicApiMiddleware).Assembly + }; + var serviceTypes = assemblies + .SelectMany(a => a.GetTypes()) .Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t)) + .Distinct() .ToList(); foreach (var type in serviceTypes) { + // 优先使用 [DynamicApiRoute] 属性指定的自定义路由前缀 + var routeAttr = type.GetCustomAttribute(); + if (routeAttr != null) + { + _serviceTypeCache[routeAttr.Route] = type; + continue; + } + var cleanName = type.Name.EndsWith("Service") ? type.Name.Substring(0, type.Name.Length - "Service".Length) : type.Name; @@ -53,10 +77,12 @@ public class DynamicApiMiddleware /// /// 管道中的下一个中间件。 /// 应用根服务容器。 - public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider) + /// 日志记录器。 + public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider, ILogger logger) { _next = next; _serviceProvider = serviceProvider; + _logger = logger; } /// @@ -111,6 +137,30 @@ public class DynamicApiMiddleware return; } + // 权限检查:AllowAnonymous 跳过鉴权,RequirePermission 要求指定权限 + // 方法级属性优先于接口级属性 + if (!IsAllowAnonymous(method, serviceType)) + { + var requiredPermissions = GetRequiredPermissions(method, serviceType); + if (requiredPermissions.Count > 0) + { + if (!(context.User.Identity?.IsAuthenticated ?? false)) + { + await WriteErrorResponse(context, 401, "未登录或会话已过期"); + return; + } + + foreach (var perm in requiredPermissions) + { + if (!context.User.HasPermission(perm)) + { + await WriteErrorResponse(context, 403, "权限不足"); + return; + } + } + } + } + try { var result = await InvokeMethod(service, method, context); @@ -118,6 +168,8 @@ public class DynamicApiMiddleware } catch (Exception ex) { + _logger.LogError(ex, "Dynamic API 调用失败: {ServiceName}.{MethodName} {HttpMethod} {Path}", + serviceName, method.Name, context.Request.Method, context.Request.Path); await WriteResponse(context, null, ex, method.Name); } } @@ -635,7 +687,8 @@ public class DynamicApiMiddleware var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); context.Response.StatusCode = exception switch @@ -647,4 +700,54 @@ public class DynamicApiMiddleware await context.Response.WriteAsync(json); } + + /// + /// 判断方法或接口是否标注了 。 + /// 方法级属性优先。 + /// + private static bool IsAllowAnonymous(MethodInfo method, Type serviceType) + { + return method.GetCustomAttribute() != null + || serviceType.GetCustomAttribute() != null; + } + + /// + /// 获取方法或接口上要求的权限列表。 + /// 若方法上标注了 ,以方法为准; + /// 否则以接口上的标注为准。 + /// + private static List GetRequiredPermissions(MethodInfo method, Type serviceType) + { + var methodPerms = method.GetCustomAttributes().ToList(); + if (methodPerms.Count > 0) + return methodPerms.Select(a => a.Permission).ToList(); + + var typePerms = serviceType.GetCustomAttributes().ToList(); + return typePerms.Select(a => a.Permission).ToList(); + } + + /// + /// 写入统一的错误响应(DynamicApi 格式)。 + /// + private static async Task WriteErrorResponse(HttpContext context, int statusCode, string message) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + + var response = new + { + Success = false, + Data = (object?)null, + Message = message, + Errors = (List?)null + }; + + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + await context.Response.WriteAsync(json); + } } diff --git a/src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs b/src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs similarity index 65% rename from src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs rename to src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs index 9ffbb2b..276fd4b 100644 --- a/src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs @@ -1,10 +1,15 @@ using System.Reflection; -using Hua.Todo.Application.Interfaces; +using System.Xml.Linq; +using System.Xml.XPath; +using Hua.Todo.HttpApi; using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Interfaces; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; using Swashbuckle.AspNetCore.SwaggerGen; using Microsoft.OpenApi; -namespace Hua.Todo.Application.DynamicApi.Swagger; +namespace Hua.Todo.HttpApi.AspNetCore.DynamicApi.Swagger; /// /// 为 Dynamic API(/api/{service}/...)补齐 OpenAPI 文档的过滤器。 @@ -13,6 +18,26 @@ namespace Hua.Todo.Application.DynamicApi.Swagger; /// public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter { + private static readonly Dictionary XmlDocCache = new(); + private static readonly object LockObj = new(); + + /// + /// 服务路由段 → Swagger 中文标签映射。 + /// + private static readonly Dictionary TagNames = new(StringComparer.OrdinalIgnoreCase) + { + ["task"] = "待办事项", + ["attachment"] = "附件管理", + ["meeting"] = "会议管理", + ["voice"] = "语音控制", + ["auth"] = "认证管理", + ["admin"] = "管理后台", + ["tasks"] = "任务同步", + ["security"] = "安全策略", + ["cloud-sync"] = "云端探测", + ["cloudsyncproxysettings"] = "云端代理设置", + }; + /// /// 将 Dynamic API 端点追加到 。 /// @@ -20,13 +45,19 @@ public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter /// Swagger 生成上下文(用于 Schema 生成与引用管理)。 public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) { - var dynamicApiAssembly = typeof(IDynamicApiService).Assembly; - var serviceInterfaces = dynamicApiAssembly - .GetTypes() + var serviceAssemblies = new[] + { + typeof(ITaskService).Assembly, + typeof(DynamicApiSwaggerDocumentFilter).Assembly + }; + var serviceInterfaces = serviceAssemblies + .SelectMany(a => a.GetTypes()) .Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t)) .Where(IsRemoteServiceEnabled) .ToList(); + var addedSegments = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var serviceInterface in serviceInterfaces) { var serviceSegment = GetServiceRouteSegment(serviceInterface); @@ -35,8 +66,18 @@ public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter continue; } + addedSegments.Add(serviceSegment); AddServiceOperations(swaggerDoc, context, serviceInterface, serviceSegment); } + + // 注册中文标签到 Swagger 文档 + swaggerDoc.Tags ??= new HashSet(); + foreach (var segment in addedSegments) + { + var tagName = GetTagName(segment); + if (!swaggerDoc.Tags.Any(t => t.Name == tagName)) + swaggerDoc.Tags.Add(new OpenApiTag { Name = tagName }); + } } private static void AddServiceOperations( @@ -82,11 +123,14 @@ public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter string httpMethod, string routeSuffix) { + var summary = GetXmlDocSummary(method) ?? method.Name; + var tagName = GetTagName(serviceSegment); + var operation = new OpenApiOperation { OperationId = $"DynamicApi_{serviceSegment}_{httpMethod}_{method.Name}", - Summary = $"{serviceSegment} - {method.Name}", - Tags = new HashSet { new OpenApiTagReference(serviceSegment, null, serviceSegment) }, + Summary = summary, + Tags = new HashSet { new OpenApiTagReference(tagName) }, Responses = BuildResponses(context, method) }; @@ -249,26 +293,55 @@ public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter { routeSuffix = string.Empty; + // 1) 显式路由模板([HttpGet("xxx")] 等) var explicitRoute = method.GetCustomAttribute()?.Route ?? method.GetCustomAttribute()?.Route ?? method.GetCustomAttribute()?.Route ?? method.GetCustomAttribute()?.Route ?? method.GetCustomAttribute()?.Route; - if (!string.IsNullOrWhiteSpace(explicitRoute)) + if (explicitRoute != null) // 允许空字符串(匹配根路由) { routeSuffix = explicitRoute.Trim(); return true; } + // 2) ITaskService 特殊路径(含路由参数) if (string.Equals(serviceInterface.Name, "ITaskService", StringComparison.OrdinalIgnoreCase)) { return TryGetTaskServiceRouteSuffix(method, out routeSuffix); } + // 3) 命名惯例:去 Get/Async 前后缀,无额外路径段 + var methodName = GetMethodNameWithoutConvention(method); + if (!string.IsNullOrEmpty(methodName)) + { + routeSuffix = string.Empty; + return true; + } + return false; } + /// + /// 按命名惯例推导方法名(去 Get/Async)。 + /// + private static string? GetMethodNameWithoutConvention(MethodInfo method) + { + var name = method.Name; + if (name.EndsWith("Async", StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - "Async".Length); + } + + if (name.StartsWith("Get", StringComparison.OrdinalIgnoreCase) && name.Length > 3) + { + return name.Substring(3); + } + + return name; + } + private static bool TryGetTaskServiceRouteSuffix(MethodInfo method, out string routeSuffix) { routeSuffix = string.Empty; @@ -296,58 +369,34 @@ public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter private static void TrySetOperation(IOpenApiPathItem pathItem, string httpMethod, OpenApiOperation operation) { - var operationsObject = pathItem.GetType().GetProperty("Operations")?.GetValue(pathItem); - if (operationsObject == null) - { + if (pathItem is not OpenApiPathItem concrete) return; - } - - if (operationsObject is System.Collections.IDictionary dict) - { - var dictType = operationsObject.GetType(); - var keyType = dictType.IsGenericType ? dictType.GetGenericArguments()[0] : typeof(object); - dict[CreateOperationKey(keyType, httpMethod)] = operation; - return; - } - - var indexer = operationsObject.GetType().GetProperty("Item"); - var keyTypeFromIndexer = indexer?.GetIndexParameters().FirstOrDefault()?.ParameterType ?? typeof(object); - indexer?.SetValue(operationsObject, operation, new[] { CreateOperationKey(keyTypeFromIndexer, httpMethod) }); - } - - private static object CreateOperationKey(Type keyType, string httpMethod) - { - if (keyType == typeof(string)) - { - return httpMethod.ToLowerInvariant(); - } - - if (keyType.IsEnum) - { - var name = httpMethod.ToUpperInvariant() switch - { - "GET" => "Get", - "POST" => "Post", - "PUT" => "Put", - "DELETE" => "Delete", - "PATCH" => "Patch", - _ => httpMethod - }; - - return Enum.Parse(keyType, name, ignoreCase: true); - } - - return httpMethod; + concrete.AddOperation(new System.Net.Http.HttpMethod(httpMethod), operation); } private static bool IsRemoteServiceEnabled(MemberInfo memberInfo) { var attribute = memberInfo.GetCustomAttribute(); - return attribute == null || attribute.IsEnabled; + if (attribute == null) + return true; + return attribute.IsMetadataEnabled; + } + + private static string GetTagName(string serviceSegment) + { + return TagNames.TryGetValue(serviceSegment, out var name) ? name : serviceSegment; } private static string GetServiceRouteSegment(Type serviceInterface) { + // 优先使用 [DynamicApiRoute] 自定义前缀 + var dynamicRouteAttr = serviceInterface.GetCustomAttribute(); + if (dynamicRouteAttr != null && !string.IsNullOrWhiteSpace(dynamicRouteAttr.Route)) + { + return dynamicRouteAttr.Route.ToLowerInvariant(); + } + + // 回退到命名惯例:I{Name}Service → {name} var name = serviceInterface.Name; if (name.EndsWith("Service", StringComparison.OrdinalIgnoreCase)) { @@ -397,4 +446,92 @@ public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter type == typeof(Guid) || Nullable.GetUnderlyingType(type) != null; } -} + + /// + /// 从 XML 文档注释中读取方法的 summary 文本。 + /// + private static string? GetXmlDocSummary(MethodInfo method) + { + var assembly = method.DeclaringType!.Assembly; + var xmlFile = Path.ChangeExtension(assembly.Location, ".xml"); + if (!File.Exists(xmlFile)) + return null; + + EnsureXmlDocLoaded(xmlFile); + + var memberName = GetXmlMemberName(method); + XmlDocCache.TryGetValue(memberName, out var summary); + return summary; + } + + private static void EnsureXmlDocLoaded(string xmlFile) + { + if (XmlDocCache.ContainsKey($"__loaded:{xmlFile}")) + return; + + lock (LockObj) + { + if (XmlDocCache.ContainsKey($"__loaded:{xmlFile}")) + return; + + try + { + var doc = XDocument.Load(xmlFile); + foreach (var member in doc.XPathSelectElements("/doc/members/member")) + { + var name = member.Attribute("name")?.Value; + if (name == null) continue; + var summary = member.Element("summary")?.Value.Trim(); + // 只取第一句(XML 文档中可能包含路由、URL 等附加说明) + if (summary != null) + { + var newlineIdx = summary.IndexOfAny(new[] { '\n', '\r' }); + if (newlineIdx > 0) + summary = summary.Substring(0, newlineIdx).Trim(); + } + XmlDocCache[name] = summary; + } + } + catch + { + // XML 文档加载失败,静默回退 + } + + XmlDocCache[$"__loaded:{xmlFile}"] = "true"; + } + } + + /// + /// 生成方法在 XML 文档注释中的成员名称(M: 前缀格式)。 + /// + private static string GetXmlMemberName(MethodInfo method) + { + var type = method.DeclaringType!; + var typeName = type.FullName!; + var methodName = method.Name; + + var parameters = method.GetParameters(); + if (parameters.Length == 0) + return $"M:{typeName}.{methodName}"; + + var paramNames = string.Join(",", parameters.Select(p => GetXmlTypeName(p.ParameterType))); + return $"M:{typeName}.{methodName}({paramNames})"; + } + + /// + /// 获取类型的 XML 文档全名(用于参数签名)。 + /// + private static string GetXmlTypeName(Type type) + { + if (type.IsGenericType) + { + var genericDef = type.GetGenericTypeDefinition(); + var defName = genericDef.FullName!; + var tickIndex = defName.IndexOf('`'); + defName = tickIndex > 0 ? defName.Substring(0, tickIndex) : defName; + var args = string.Join(",", type.GetGenericArguments().Select(GetXmlTypeName)); + return $"{defName}{{{args}}}"; + } + return type.FullName!; + } +} \ No newline at end of file diff --git a/src/Hua.Todo.HttpApi.AspNetCore/HttpContextCurrentUserAccessor.cs b/src/Hua.Todo.HttpApi.AspNetCore/HttpContextCurrentUserAccessor.cs new file mode 100644 index 0000000..84d3731 --- /dev/null +++ b/src/Hua.Todo.HttpApi.AspNetCore/HttpContextCurrentUserAccessor.cs @@ -0,0 +1,47 @@ +using Hua.Todo.Application.Services.CloudSync.Auth; +using Hua.Todo.Application.Services.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Hua.Todo.HttpApi.AspNetCore; + +/// +/// 基于 ASP.NET Core 的当前用户访问器。 +/// 注册为 Scoped 服务,从 HTTP 上下文获取当前登录用户信息。 +/// +public class HttpContextCurrentUserAccessor : ICurrentUserAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// 创建 。 + /// + /// HTTP 上下文访问器。 + public HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + public Guid? GetCurrentUserId() + { + return _httpContextAccessor.HttpContext?.User.GetUserId(); + } + + /// + public Guid? GetCurrentSessionId() + { + return _httpContextAccessor.HttpContext?.User.GetSessionId(); + } + + /// + public string? GetClientIp() + { + return _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); + } + + /// + public string? GetUserAgent() + { + return _httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString(); + } +} diff --git a/src/Hua.Todo.HttpApi.AspNetCore/Hua.Todo.HttpApi.AspNetCore.csproj b/src/Hua.Todo.HttpApi.AspNetCore/Hua.Todo.HttpApi.AspNetCore.csproj new file mode 100644 index 0000000..b5db026 --- /dev/null +++ b/src/Hua.Todo.HttpApi.AspNetCore/Hua.Todo.HttpApi.AspNetCore.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + Library + true + $(NoWarn);CS1591 + + + + + + + + + + + + + + + + + + diff --git a/src/Hua.Todo.Application/Mcp/DynamicMcpToolExtensions.cs b/src/Hua.Todo.HttpApi.AspNetCore/Mcp/DynamicMcpToolExtensions.cs similarity index 80% rename from src/Hua.Todo.Application/Mcp/DynamicMcpToolExtensions.cs rename to src/Hua.Todo.HttpApi.AspNetCore/Mcp/DynamicMcpToolExtensions.cs index 5dd61f1..2a76d39 100644 --- a/src/Hua.Todo.Application/Mcp/DynamicMcpToolExtensions.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/Mcp/DynamicMcpToolExtensions.cs @@ -1,11 +1,14 @@ using System.ComponentModel; using System.Reflection; -using Hua.Todo.Application.Interfaces; +using Hua.Todo.Application.Services.Interfaces; +using Hua.Todo.HttpApi; +using Hua.Todo.HttpApi.Attributes; +using Hua.Todo.HttpApi.Interfaces; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -namespace Hua.Todo.Application.Mcp; +namespace Hua.Todo.HttpApi.AspNetCore.Mcp; /// /// 从 接口自动生成 MCP 工具的扩展方法。 @@ -26,12 +29,20 @@ public static class DynamicMcpToolExtensions /// MCP Server 构建器。 public static IMcpServerBuilder WithDynamicApiTools(this IMcpServerBuilder builder) { - var assembly = typeof(IDynamicApiService).Assembly; + // 扫描 Application 程序集 + 本程序集(CloudSync 服务) + var assemblies = new[] + { + typeof(ITaskService).Assembly, + typeof(DynamicMcpToolExtensions).Assembly + }; - var serviceInterfaces = assembly.GetTypes() + var serviceInterfaces = assemblies + .SelectMany(a => a.GetTypes()) .Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t) - && t != typeof(IDynamicApiService)) + && t != typeof(IDynamicApiService) + && !IsCloudSyncService(t) + && IsRemoteServiceEnabled(t)) .ToList(); foreach (var serviceType in serviceInterfaces) @@ -165,5 +176,25 @@ public static class DynamicMcpToolExtensions private static bool IsDestructiveMethod(string name) => name.StartsWith("Delete") || name.StartsWith("Remove"); + /// + /// 检查服务接口是否启用了远程暴露(未标记或 = true)。 + /// + private static bool IsRemoteServiceEnabled(Type type) + { + var attribute = type.GetCustomAttribute(); + return attribute == null || attribute.IsEnabled; + } + + /// + /// CloudSync 服务需要认证,不应暴露为 MCP 工具。 + /// + private static bool IsCloudSyncService(Type type) + { + var ns = type.Namespace; + if (ns == null) return false; + return ns.StartsWith("Hua.Todo.Application.Services.CloudSync", StringComparison.Ordinal) + || ns.StartsWith("Hua.Todo.HttpApi.AspNetCore.CloudSync", StringComparison.Ordinal); + } + #endregion -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Mcp/McpEndpointExtensions.cs b/src/Hua.Todo.HttpApi.AspNetCore/Mcp/McpEndpointExtensions.cs similarity index 95% rename from src/Hua.Todo.Application/Mcp/McpEndpointExtensions.cs rename to src/Hua.Todo.HttpApi.AspNetCore/Mcp/McpEndpointExtensions.cs index 7273c00..fa0b46f 100644 --- a/src/Hua.Todo.Application/Mcp/McpEndpointExtensions.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/Mcp/McpEndpointExtensions.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; -namespace Hua.Todo.Application.Mcp; +namespace Hua.Todo.HttpApi.AspNetCore.Mcp; /// /// MCP Server 端点映射扩展方法。 @@ -21,4 +21,4 @@ public static class McpEndpointExtensions endpoints.MapMcp(pattern); return endpoints; } -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Mcp/McpServiceCollectionExtensions.cs b/src/Hua.Todo.HttpApi.AspNetCore/Mcp/McpServiceCollectionExtensions.cs similarity index 96% rename from src/Hua.Todo.Application/Mcp/McpServiceCollectionExtensions.cs rename to src/Hua.Todo.HttpApi.AspNetCore/Mcp/McpServiceCollectionExtensions.cs index d95df3a..95a1af9 100644 --- a/src/Hua.Todo.Application/Mcp/McpServiceCollectionExtensions.cs +++ b/src/Hua.Todo.HttpApi.AspNetCore/Mcp/McpServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Server; -namespace Hua.Todo.Application.Mcp; +namespace Hua.Todo.HttpApi.AspNetCore.Mcp; /// /// MCP Server 依赖注入扩展方法。 @@ -38,4 +38,4 @@ public static class McpServiceCollectionExtensions return services; } -} +} \ No newline at end of file diff --git a/src/Hua.Todo.HttpApi/Attributes/AllowAnonymousAttribute.cs b/src/Hua.Todo.HttpApi/Attributes/AllowAnonymousAttribute.cs new file mode 100644 index 0000000..36b9d03 --- /dev/null +++ b/src/Hua.Todo.HttpApi/Attributes/AllowAnonymousAttribute.cs @@ -0,0 +1,9 @@ +namespace Hua.Todo.HttpApi.Attributes; + +/// +/// 标记 Dynamic API 方法或服务允许匿名访问(跳过鉴权)。 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface, AllowMultiple = false)] +public class AllowAnonymousAttribute : Attribute +{ +} diff --git a/src/Hua.Todo.HttpApi/Attributes/DynamicApiRouteAttribute.cs b/src/Hua.Todo.HttpApi/Attributes/DynamicApiRouteAttribute.cs new file mode 100644 index 0000000..4253b46 --- /dev/null +++ b/src/Hua.Todo.HttpApi/Attributes/DynamicApiRouteAttribute.cs @@ -0,0 +1,25 @@ +using Hua.Todo.HttpApi.Interfaces; + +namespace Hua.Todo.HttpApi.Attributes; + +/// +/// 为 接口指定自定义路由前缀。 +/// 未标注时,路由前缀由接口名自动推导(去 I 前缀和 Service 后缀)。 +/// +[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)] +public class DynamicApiRouteAttribute : Attribute +{ + /// + /// 自定义路由片段名。例如 "auth" 使服务映射到 /api/auth。 + /// + public string Route { get; } + + /// + /// 创建 实例。 + /// + /// 自定义路由片段名。 + public DynamicApiRouteAttribute(string route) + { + Route = route; + } +} diff --git a/src/Hua.Todo.Application/DynamicApi/HttpAttributes.cs b/src/Hua.Todo.HttpApi/Attributes/HttpAttributes.cs similarity index 98% rename from src/Hua.Todo.Application/DynamicApi/HttpAttributes.cs rename to src/Hua.Todo.HttpApi/Attributes/HttpAttributes.cs index b9784d1..ce6045d 100644 --- a/src/Hua.Todo.Application/DynamicApi/HttpAttributes.cs +++ b/src/Hua.Todo.HttpApi/Attributes/HttpAttributes.cs @@ -1,9 +1,9 @@ -namespace Hua.Todo.Application.DynamicApi; +namespace Hua.Todo.HttpApi.Attributes; -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] /// /// 标记方法为 HTTP GET。 /// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class HttpGetAttribute : Attribute { /// @@ -28,10 +28,10 @@ public class HttpGetAttribute : Attribute } } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] /// /// 标记方法为 HTTP POST。 /// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class HttpPostAttribute : Attribute { /// @@ -56,10 +56,10 @@ public class HttpPostAttribute : Attribute } } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] /// /// 标记方法为 HTTP PUT。 /// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class HttpPutAttribute : Attribute { /// @@ -84,10 +84,10 @@ public class HttpPutAttribute : Attribute } } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] /// /// 标记方法为 HTTP DELETE。 /// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class HttpDeleteAttribute : Attribute { /// @@ -112,10 +112,10 @@ public class HttpDeleteAttribute : Attribute } } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] /// /// 标记方法为 HTTP PATCH。 /// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class HttpPatchAttribute : Attribute { /// diff --git a/src/Hua.Todo.Application/DynamicApi/ParameterBindingAttributes.cs b/src/Hua.Todo.HttpApi/Attributes/ParameterBindingAttributes.cs similarity index 89% rename from src/Hua.Todo.Application/DynamicApi/ParameterBindingAttributes.cs rename to src/Hua.Todo.HttpApi/Attributes/ParameterBindingAttributes.cs index ea72aad..34ee869 100644 --- a/src/Hua.Todo.Application/DynamicApi/ParameterBindingAttributes.cs +++ b/src/Hua.Todo.HttpApi/Attributes/ParameterBindingAttributes.cs @@ -1,17 +1,17 @@ -namespace Hua.Todo.Application.DynamicApi; +namespace Hua.Todo.HttpApi.Attributes; -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] /// /// 指示参数从 QueryString 绑定。 /// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] public class FromQueryAttribute : Attribute { } -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] /// /// 指示参数从 Request Body 绑定。 /// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] public class FromBodyAttribute : Attribute { } diff --git a/src/Hua.Todo.Application/DynamicApi/RemoteServiceAttribute.cs b/src/Hua.Todo.HttpApi/Attributes/RemoteServiceAttribute.cs similarity index 92% rename from src/Hua.Todo.Application/DynamicApi/RemoteServiceAttribute.cs rename to src/Hua.Todo.HttpApi/Attributes/RemoteServiceAttribute.cs index 4f2595d..2c8a957 100644 --- a/src/Hua.Todo.Application/DynamicApi/RemoteServiceAttribute.cs +++ b/src/Hua.Todo.HttpApi/Attributes/RemoteServiceAttribute.cs @@ -1,9 +1,9 @@ -namespace Hua.Todo.Application.DynamicApi; +namespace Hua.Todo.HttpApi.Attributes; -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)] /// /// 标记服务/方法是否允许通过 Dynamic API 暴露。 /// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)] public class RemoteServiceAttribute : Attribute { /// diff --git a/src/Hua.Todo.HttpApi/Attributes/RequirePermissionAttribute.cs b/src/Hua.Todo.HttpApi/Attributes/RequirePermissionAttribute.cs new file mode 100644 index 0000000..f556b83 --- /dev/null +++ b/src/Hua.Todo.HttpApi/Attributes/RequirePermissionAttribute.cs @@ -0,0 +1,24 @@ +namespace Hua.Todo.HttpApi.Attributes; + +/// +/// 标记需要特定权限才能调用的 Dynamic API 方法或服务。 +/// 可用于接口方法或整个接口(服务级)。 +/// 与 ASP.NET Core 的 AllowAnonymousAttribute 配合使用。 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface, AllowMultiple = true)] +public class RequirePermissionAttribute : Attribute +{ + /// + /// 所需的权限点(如 "tasks:read")。 + /// + public string Permission { get; } + + /// + /// 创建 实例。 + /// + /// 所需的权限常量值。 + public RequirePermissionAttribute(string permission) + { + Permission = permission; + } +} diff --git a/src/Hua.Todo.HttpApi/Hua.Todo.HttpApi.csproj b/src/Hua.Todo.HttpApi/Hua.Todo.HttpApi.csproj new file mode 100644 index 0000000..c4f8694 --- /dev/null +++ b/src/Hua.Todo.HttpApi/Hua.Todo.HttpApi.csproj @@ -0,0 +1,12 @@ + + + + net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst + enable + enable + Library + true + $(NoWarn);CS1591 + + + diff --git a/src/Hua.Todo.Application/Interfaces/IDynamicApiService.cs b/src/Hua.Todo.HttpApi/Interfaces/IDynamicApiService.cs similarity index 82% rename from src/Hua.Todo.Application/Interfaces/IDynamicApiService.cs rename to src/Hua.Todo.HttpApi/Interfaces/IDynamicApiService.cs index 5e24545..8d5a08b 100644 --- a/src/Hua.Todo.Application/Interfaces/IDynamicApiService.cs +++ b/src/Hua.Todo.HttpApi/Interfaces/IDynamicApiService.cs @@ -1,4 +1,4 @@ -namespace Hua.Todo.Application.Interfaces; +namespace Hua.Todo.HttpApi.Interfaces; /// /// Dynamic API 服务标记接口。 diff --git a/src/Hua.Todo.Maui/Converters/PriorityToColorConverter.cs b/src/Hua.Todo.Maui/Converters/PriorityToColorConverter.cs index 6937765..1f48d6f 100644 --- a/src/Hua.Todo.Maui/Converters/PriorityToColorConverter.cs +++ b/src/Hua.Todo.Maui/Converters/PriorityToColorConverter.cs @@ -5,7 +5,7 @@ namespace Hua.Todo.Maui.Converters { public class PriorityToColorConverter : IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { if (value is string priority) { @@ -20,9 +20,18 @@ namespace Hua.Todo.Maui.Converters return Colors.White; } - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + /// + /// 将颜色反向转换为优先级字符串(实际场景中极少调用) + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { - throw new NotImplementedException(); + if (value is Color color) + { + if (color.Equals(Color.FromRgb(255, 205, 210))) return "高"; + if (color.Equals(Color.FromRgb(255, 224, 178))) return "中"; + if (color.Equals(Color.FromRgb(200, 230, 201))) return "低"; + } + return "中"; } } } diff --git a/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj b/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj index f233df9..6305192 100644 --- a/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj +++ b/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj @@ -45,8 +45,8 @@ 15.0 15.0 21.0 - 10.0.17763.0 - 10.0.17763.0 + 10.0.19041.0 + 10.0.19041.0 $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web')) $(TodoWebDir)\dist @@ -95,7 +95,6 @@ - @@ -112,12 +111,24 @@ + + + + + + + + + + + + @@ -133,7 +144,6 @@ - diff --git a/src/Hua.Todo.Maui/MauiProgram.cs b/src/Hua.Todo.Maui/MauiProgram.cs index e409848..43f13c1 100644 --- a/src/Hua.Todo.Maui/MauiProgram.cs +++ b/src/Hua.Todo.Maui/MauiProgram.cs @@ -2,8 +2,13 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Data.Sqlite; -using Hua.Todo.Application; -using Hua.Todo.Application.Data; +using Serilog; +using Hua.Todo.Application.Common; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.Interfaces; +#if !WINDOWS +using Hua.Todo.HttpApi.Android.DynamicApi; +#endif using Hua.Todo.Maui.Models; using Hua.Todo.Maui.Services; using Hua.Todo.Maui.Services.Platforms; @@ -15,11 +20,28 @@ namespace Hua.Todo.Maui; /// public static class MauiProgram { + /// + /// MAUI 客户端日志目录。 + /// 开发环境(DEBUG)统一到项目根目录 logs/;发布后使用 AppData。 + /// + private static string LogDirectory => +#if DEBUG + Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "logs")); +#else + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Hua.Todo", + "logs"); +#endif + /// /// 创建并配置 MAUI 应用程序 /// public static MauiApp CreateMauiApp() { + // 配置 Serilog 文件日志(Console + 按天滚动文件) + Log.Logger = LoggingConfiguration.Create(LogDirectory).CreateLogger(); + var builder = MauiApp.CreateBuilder(); builder .UseMauiApp() @@ -35,6 +57,10 @@ public static class MauiProgram // 如果未提供连接字符串,则设置默认的 SQLite 连接字符串 if (string.IsNullOrEmpty(appSettings.WebServer.ConnectionString)) { + // 开发环境使用项目根目录(方便调试);发布后使用 AppData +#if DEBUG + var dbPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "Hua.Todo.db")); +#else var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var dbDir = Path.Combine(localAppData, "Hua.Todo"); if (!Directory.Exists(dbDir)) @@ -42,6 +68,7 @@ public static class MauiProgram Directory.CreateDirectory(dbDir); } var dbPath = Path.Combine(dbDir, "Hua.Todo.db"); +#endif appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared"; } @@ -78,6 +105,11 @@ public static class MauiProgram builder.Services.AddSingleton(); #endif +#if !WINDOWS + // 注册 DynamicApiHandler(移动端:Android TcpListener / iOS-macCatalyst Noop) + builder.Services.AddDynamicApiHandler(typeof(ITaskService).Assembly); +#endif + #if DEBUG builder.Logging.AddDebug(); #endif @@ -94,7 +126,7 @@ public static class MauiProgram } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"App initialization failed: {ex}"); + Log.Error(ex, "App initialization failed"); } }); @@ -161,15 +193,31 @@ public static class MauiProgram } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Database initialization failed: {ex.Message}"); + Log.Error(ex, "Database initialization failed, attempting recreation"); try { - var context = scope.ServiceProvider.GetRequiredService(); - context.Database.EnsureCreated(); + // 删除损坏的数据库文件并重新创建 + var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString); + var dbPath = sqliteBuilder.DataSource; + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + File.Delete(dbPath); + // 同时删除 WAL/SHM 文件 + var walPath = dbPath + "-wal"; + var shmPath = dbPath + "-shm"; + if (File.Exists(walPath)) File.Delete(walPath); + if (File.Exists(shmPath)) File.Delete(shmPath); + } + + using var newScope = services.CreateScope(); + var newContext = newScope.ServiceProvider.GetRequiredService(); + newContext.Database.EnsureCreated(); + Log.Information("Database recreated successfully via EnsureCreated"); } - catch + catch (Exception ensureEx) { + Log.Error(ensureEx, "Database recreation failed"); } } } @@ -186,7 +234,7 @@ public static class MauiProgram } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Web server start failed: {ex}"); + Log.Error(ex, "Web server start failed"); } } } diff --git a/src/Hua.Todo.Maui/Models/AppSettings.cs b/src/Hua.Todo.Maui/Models/AppSettings.cs index e5b8803..5039f68 100644 --- a/src/Hua.Todo.Maui/Models/AppSettings.cs +++ b/src/Hua.Todo.Maui/Models/AppSettings.cs @@ -26,9 +26,11 @@ public class WebServerSettings [JsonPropertyName("HostUrl")] public string HostUrl { get; set; } = "http://localhost:5057"; - [JsonPropertyName("ForEndUrl")] public string ForEndUrl { get; set; } = "http://localhost:5174"; + + [JsonPropertyName("CloudSyncUrl")] + public string CloudSyncUrl { get; set; } = ""; } public class HotKeyDefaultSettings diff --git a/src/Hua.Todo.Maui/Platforms/Android/MainActivity.cs b/src/Hua.Todo.Maui/Platforms/Android/MainActivity.cs index 19247b5..83aa8e9 100644 --- a/src/Hua.Todo.Maui/Platforms/Android/MainActivity.cs +++ b/src/Hua.Todo.Maui/Platforms/Android/MainActivity.cs @@ -4,10 +4,10 @@ using Android.OS; namespace Hua.Todo.Maui; -[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] /// /// Android 平台入口 Activity。 /// +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] public class MainActivity : MauiAppCompatActivity { /// diff --git a/src/Hua.Todo.Maui/Platforms/Android/MainApplication.cs b/src/Hua.Todo.Maui/Platforms/Android/MainApplication.cs index c536428..84b9ce9 100644 --- a/src/Hua.Todo.Maui/Platforms/Android/MainApplication.cs +++ b/src/Hua.Todo.Maui/Platforms/Android/MainApplication.cs @@ -3,10 +3,10 @@ using Android.Runtime; namespace Hua.Todo.Maui; -[Application] /// /// Android 平台 Application。 /// +[Application] public class MainApplication : MauiApplication { /// diff --git a/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs b/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs index 5d08f2d..51208b1 100644 --- a/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs +++ b/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs @@ -1,10 +1,8 @@ using System.Net; using System.Net.Sockets; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Hua.Todo.Application.Interfaces; -using Hua.Todo.Application.Models; +using Serilog; +using Hua.Todo.HttpApi.Android.DynamicApi; using Hua.Todo.Maui.Models; namespace Hua.Todo.Maui.Services; @@ -12,11 +10,13 @@ namespace Hua.Todo.Maui.Services; /// /// Android 平台嵌入式 Web 服务器服务。 /// 使用 TcpListener 自定义实现简单的 HTTP 服务器,用于离线模式下托管 API 和静态资源。 +/// 通过 实现与 AspNetCore 版本等价的 Dynamic API 路由分发。 /// public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService { private readonly AppSettings _appSettings; private readonly IServiceProvider _services; + private readonly DynamicApiHandler _apiHandler; private TcpListener? _listener; private CancellationTokenSource? _cts; @@ -37,10 +37,12 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService /// /// 应用程序配置。 /// 依赖注入服务提供者。 - public MobileEmbeddedWebServerService(AppSettings appSettings, IServiceProvider services) + /// Dynamic API 请求分发器。 + public MobileEmbeddedWebServerService(AppSettings appSettings, IServiceProvider services, DynamicApiHandler apiHandler) { _appSettings = appSettings; _services = services; + _apiHandler = apiHandler; } /// @@ -75,24 +77,27 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService { _cts?.Cancel(); } - catch + catch (Exception ex) { + Log.Error(ex, "[MobileEmbeddedWebServer] StopAsync cancel failed"); } try { _listener.Stop(); } - catch + catch (Exception ex2) { + Log.Warning(ex2, "[MobileEmbeddedWebServer] Operation cancelled during shutdown"); } try { if (_acceptLoop != null) await _acceptLoop; } - catch + catch (Exception ex2) { + Log.Warning(ex2, "[MobileEmbeddedWebServer] Operation cancelled during shutdown"); } _listener = null; @@ -111,10 +116,15 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService client = await _listener.AcceptTcpClientAsync(token); _ = Task.Run(() => HandleClientAsync(client, token), token); } - catch - { - client?.Dispose(); - } + catch (OperationCanceledException) + { + client?.Dispose(); + } + catch (Exception ex) + { + Log.Error(ex, "[MobileEmbeddedWebServer] AcceptTcpClientAsync failed"); + client?.Dispose(); + } } } @@ -128,8 +138,9 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService { request = await ReadRequestAsync(stream, token); } - catch + catch (Exception ex) { + Log.Warning(ex, "[MobileEmbeddedWebServer] Failed to read HTTP request"); return; } @@ -146,96 +157,13 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService { try { - var path = request.Path; - if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) - { - await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token); - return; - } - - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length < 2) - { - await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token); - return; - } - - var serviceName = segments[1]; - if (!serviceName.Equals("task", StringComparison.OrdinalIgnoreCase)) - { - await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token); - return; - } - - using var scope = _services.CreateScope(); - var taskService = scope.ServiceProvider.GetRequiredService(); - - object? result = null; - var methodName = string.Empty; - - var tail = segments.Skip(2).ToArray(); - - if (request.Method == "GET" && tail.Length == 0) - { - methodName = nameof(ITaskService.GetAllTasksAsync); - result = await taskService.GetAllTasksAsync(); - } - else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("active", StringComparison.OrdinalIgnoreCase)) - { - methodName = nameof(ITaskService.GetActiveTasksAsync); - result = await taskService.GetActiveTasksAsync(); - } - else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("completed", StringComparison.OrdinalIgnoreCase)) - { - methodName = nameof(ITaskService.GetCompletedTasksAsync); - result = await taskService.GetCompletedTasksAsync(); - } - else if (request.Method == "GET" && tail.Length == 1 && int.TryParse(tail[0], out var getId)) - { - methodName = nameof(ITaskService.GetTaskByIdAsync); - result = await taskService.GetTaskByIdAsync(getId); - } - else if (request.Method == "GET" && tail.Length == 2 && tail[1].Equals("subtasks", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var parentId)) - { - methodName = nameof(ITaskService.GetSubTasksAsync); - result = await taskService.GetSubTasksAsync(parentId); - } - else if (request.Method == "POST" && tail.Length == 0) - { - methodName = nameof(ITaskService.CreateTaskAsync); - var dto = DeserializeBody(request.Body); - result = await taskService.CreateTaskAsync(dto); - } - else if (request.Method == "PUT" && tail.Length == 0) - { - methodName = nameof(ITaskService.UpdateTaskAsync); - var dto = DeserializeBody(request.Body); - result = await taskService.UpdateTaskAsync(dto); - } - else if (request.Method == "PATCH" && tail.Length == 2 && tail[1].Equals("toggle", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var toggleId)) - { - methodName = nameof(ITaskService.ToggleCompleteAsync); - result = await taskService.ToggleCompleteAsync(toggleId); - } - else if (request.Method == "DELETE" && tail.Length == 1 && int.TryParse(tail[0], out var deleteId)) - { - methodName = nameof(ITaskService.DeleteTaskAsync); - await taskService.DeleteTaskAsync(deleteId); - result = null; - } - else - { - await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token); - return; - } - - var response = CreateApiResponse(result, null, methodName); - await WriteJsonAsync(stream, response.StatusCode, response.Payload, token); + var (statusCode, json) = await _apiHandler.HandleAsync(request.Method, request.Path, request.Body); + await WriteTextAsync(stream, statusCode, json, "application/json; charset=utf-8", token); } catch (Exception ex) { - var response = CreateApiResponse(null, ex, string.Empty); - await WriteJsonAsync(stream, response.StatusCode, response.Payload, token); + Log.Error(ex, "[MobileEmbeddedWebServer] DynamicApi 调用失败: {Method} {Path}", request.Method, request.Path); + await WriteTextAsync(stream, 500, "{\"success\":false,\"message\":\"Internal Server Error\"}", "application/json; charset=utf-8", token); } } @@ -316,74 +244,6 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService }; } - private static (int StatusCode, object Payload) CreateApiResponse(object? result, Exception? exception, string methodName) - { - var successMessage = methodName switch - { - _ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功", - _ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功", - _ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功", - _ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功", - _ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功", - _ => "操作成功" - }; - - var failureMessage = methodName switch - { - _ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败", - _ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败", - _ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败", - _ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败", - _ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败", - _ => "操作失败" - }; - - List? errors = null; - if (exception != null) - { - errors = new List { exception.Message }; - } - - var payload = new - { - Success = exception == null, - Data = exception == null ? result : null, - Message = exception == null ? successMessage : failureMessage, - Errors = errors - }; - - var statusCode = exception switch - { - KeyNotFoundException => 404, - ArgumentException => 400, - _ => 200 - }; - - return (statusCode, payload); - } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() } - }; - - private static T DeserializeBody(string body) where T : new() - { - if (string.IsNullOrWhiteSpace(body)) return new T(); - return JsonSerializer.Deserialize(body, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() } - }) ?? new T(); - } - - private static async Task WriteJsonAsync(Stream stream, int statusCode, object payload, CancellationToken token) - { - var json = JsonSerializer.Serialize(payload, JsonOptions); - await WriteTextAsync(stream, statusCode, json, "application/json; charset=utf-8", token); - } - private static async Task WriteTextAsync(Stream stream, int statusCode, string body, string contentType, CancellationToken token) { var bytes = Encoding.UTF8.GetBytes(body); diff --git a/src/Hua.Todo.Maui/Platforms/Windows/App.Windows.cs b/src/Hua.Todo.Maui/Platforms/Windows/App.Windows.cs index 7d644e5..7dde003 100644 --- a/src/Hua.Todo.Maui/Platforms/Windows/App.Windows.cs +++ b/src/Hua.Todo.Maui/Platforms/Windows/App.Windows.cs @@ -157,7 +157,6 @@ public partial class App new Label { Text = AppMetadata.GetTitleBarVersionText(), - FontFamily = "Microsoft YaHei UI", TextColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"), VerticalTextAlignment = Microsoft.Maui.TextAlignment.Center, VerticalOptions = LayoutOptions.Center, diff --git a/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs b/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs index c192dba..29e5a4e 100644 --- a/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs +++ b/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs @@ -2,12 +2,18 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using Serilog; using System.Text.Json; -using Hua.Todo.Application; -using Hua.Todo.Application.DynamicApi; +using Hua.Todo.Application.Common; +using Hua.Todo.Application.Repositories; +using Hua.Todo.HttpApi; +using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; +using Hua.Todo.HttpApi.AspNetCore.DynamicApi; using Hua.Todo.Maui.Models; using AppSettings = Hua.Todo.Maui.Models.AppSettings; @@ -53,12 +59,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService if (_webApp != null) return; var builder = WebApplication.CreateBuilder(); + builder.Host.UseSerilog(); builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl); // 配置控制器和 JSON 选项 builder.Services.AddControllers() - .AddApplicationPart(typeof(Hua.Todo.Application.ServiceCollectionExtensions).Assembly) + .AddApplicationPart(typeof(Hua.Todo.Application.Common.ServiceCollectionExtensions).Assembly) .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; @@ -69,6 +76,9 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService // 注册应用逻辑服务 builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); + // 注册云同步反向代理(URL 管理 + 转发用 HttpClient) + builder.Services.AddCloudSyncProxy(); + // 配置跨域策略 builder.Services.AddCors(options => { @@ -82,6 +92,16 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService var app = builder.Build(); + // 从 appsettings 初始化持久化路径和 CloudSyncUrl + var proxyService = app.Services.GetRequiredService(); + proxyService.PersistencePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl)) + { + proxyService.CloudSyncUrl = _appSettings.WebServer.CloudSyncUrl; + } + + InitializeDatabase(app); + // 如果配置为使用静态文件(前端托管),则配置静态文件服务 if (_appSettings.WebServer.IsUsingStatic) { @@ -89,6 +109,7 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService } app.UseCors("AllowAll"); + app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权) app.UseAuthorization(); app.UseDynamicApi(); app.MapControllers(); @@ -109,7 +130,7 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); if (!Directory.Exists(wwwrootPath)) { - Console.WriteLine("[EmbeddedWebServer] wwwroot directory not found. Static file serving disabled."); + Log.Warning("[EmbeddedWebServer] wwwroot directory not found. Static file serving disabled."); return; } var fileProvider = new PhysicalFileProvider(wwwrootPath); @@ -147,11 +168,68 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService await next(); }); - Console.WriteLine($"[EmbeddedWebServer] Serving static files from: {wwwrootPath}"); + Log.Information("[EmbeddedWebServer] Serving static files from: {Path}", wwwrootPath); } catch (Exception ex) { - Console.WriteLine($"[EmbeddedWebServer] Failed to serve static files: {ex.Message}"); + Log.Error(ex, "[EmbeddedWebServer] Failed to serve static files"); + } + } + + /// + /// 初始化数据库(执行迁移) + /// + /// Web 应用程序实例 + private void InitializeDatabase(WebApplication app) + { + using var scope = app.Services.CreateScope(); + + try + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString); + var actualDbPath = sqliteBuilder.DataSource; + if (!string.IsNullOrEmpty(actualDbPath)) + { + var dbDir = Path.GetDirectoryName(actualDbPath); + if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir)) + { + Directory.CreateDirectory(dbDir); + } + } + + dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;"); + dbContext.Database.Migrate(); + } + catch (Exception ex) + { + Log.Error(ex, "[EmbeddedWebServer] Database initialization failed, attempting recreation"); + + try + { + // 删除损坏的数据库文件并重新创建 + var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString); + var dbPath = sqliteBuilder.DataSource; + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + File.Delete(dbPath); + // 同时删除 WAL/SHM 文件 + var walPath = dbPath + "-wal"; + var shmPath = dbPath + "-shm"; + if (File.Exists(walPath)) File.Delete(walPath); + if (File.Exists(shmPath)) File.Delete(shmPath); + } + + using var newScope = app.Services.CreateScope(); + var newContext = newScope.ServiceProvider.GetRequiredService(); + newContext.Database.EnsureCreated(); + Log.Information("[EmbeddedWebServer] Database recreated successfully via EnsureCreated"); + } + catch (Exception ensureEx) + { + Log.Error(ensureEx, "[EmbeddedWebServer] Database recreation failed"); + } } } diff --git a/src/Hua.Todo.Maui/Services/Platforms/MauiAttachmentOpener.cs b/src/Hua.Todo.Maui/Services/Platforms/MauiAttachmentOpener.cs new file mode 100644 index 0000000..7af9613 --- /dev/null +++ b/src/Hua.Todo.Maui/Services/Platforms/MauiAttachmentOpener.cs @@ -0,0 +1,22 @@ +using Hua.Todo.Core.Services; +using System.Diagnostics; + +namespace Hua.Todo.Maui.Services.Platforms; + +/// +/// Windows MAUI 平台文件/链接打开实现。 +/// 使用 ShellExecute 启动系统关联程序。 +/// +public class MauiAttachmentOpener : IPlatformAttachmentOpener +{ + /// + public Task OpenAsync(string path) + { + Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = true + }); + return Task.CompletedTask; + } +} diff --git a/src/Hua.Todo.Maui/appsettings.json b/src/Hua.Todo.Maui/appsettings.json index bac8595..842ac5f 100644 --- a/src/Hua.Todo.Maui/appsettings.json +++ b/src/Hua.Todo.Maui/appsettings.json @@ -1,7 +1,7 @@ { "WebServer": { "Port": 5057, - "IsUsingStatic": true, + "IsUsingStatic": false, "ConnectionString": "", "HostUrl": "http://localhost:5057", "ForEndUrl": "http://localhost:5174" diff --git a/src/Hua.Todo.Maui/icon.jpg b/src/Hua.Todo.Maui/icon.jpg deleted file mode 100644 index 3d7e280425961c5b4c3b4bd652b0c2b6d6328bb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50666 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%0D()| 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�*xABpx7z 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 a&9al=gXEz*X`vp10jZI!UnYE!fB)G(*1$Rd diff --git a/src/Hua.Todo.Web/.env.development b/src/Hua.Todo.Web/.env.development index 089aacd..60c987d 100644 --- a/src/Hua.Todo.Web/.env.development +++ b/src/Hua.Todo.Web/.env.development @@ -1,2 +1,2 @@ # Dev 模式:API 与云同步共用本地后端代理目标 -VITE_API_TARGET=http://localhost:5173 +VITE_API_TARGET=http://localhost:5057 diff --git a/src/Hua.Todo.Web/.env.production b/src/Hua.Todo.Web/.env.production index 71b846a..dd5bc75 100644 --- a/src/Hua.Todo.Web/.env.production +++ b/src/Hua.Todo.Web/.env.production @@ -1,2 +1,2 @@ # Production 模式:API 与云同步共用本地后端代理目标 -VITE_API_TARGET=http://localhost:5173 +VITE_API_TARGET=http://localhost:5057 diff --git a/src/Hua.Todo.Web/src/App.vue b/src/Hua.Todo.Web/src/App.vue index a418d51..3b0654e 100644 --- a/src/Hua.Todo.Web/src/App.vue +++ b/src/Hua.Todo.Web/src/App.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue'; import TaskList from './components/TaskList.vue'; import HotKeySettingsDialog from './components/HotKeySettingsDialog.vue'; +import CloudSyncSettingsDialog from './components/CloudSyncSettingsDialog.vue'; const toastMessage = ref(''); const toastType = ref<'error' | 'success'>('error'); @@ -26,6 +27,7 @@ onMounted(() => {
+ diff --git a/src/Hua.Todo.Web/src/api/attachments.ts b/src/Hua.Todo.Web/src/api/attachments.ts new file mode 100644 index 0000000..3fc42d2 --- /dev/null +++ b/src/Hua.Todo.Web/src/api/attachments.ts @@ -0,0 +1,135 @@ +import apiClient from './client'; +import type { AttachmentItem, ApiResponse } from '../types/task'; + +export interface UploadAttachmentRequest { + taskId: string; + fileName: string; + base64Content: string; + contentType: string; +} + +export interface AddLinkRequest { + taskId: string; + url: string; + fileName?: string; +} + +/** + * 附件管理 API 模块。 + * 提供文件上传、外部链接添加、附件列表、删除、下载和打开功能。 + */ +export const attachmentApi = { + /** + * 上传文件附件。 + * @param taskId 待办项 ID + * @param fileName 原始文件名 + * @param base64Content Base64 编码的文件内容 + * @param contentType MIME 类型 + * @returns 上传后的附件信息 + */ + async upload( + taskId: string, + fileName: string, + base64Content: string, + contentType: string + ): Promise> { + const response = await apiClient.post>( + '/attachment/upload', + { taskId, fileName, base64Content, contentType } + ); + + if (response.data && 'success' in response.data) { + return response.data as ApiResponse; + } + return { success: true, data: response.data as AttachmentItem, message: '上传成功' }; + }, + + /** + * 添加外部链接附件。 + * @param taskId 待办项 ID + * @param url 外部链接 URL + * @param fileName 链接显示名称(可选) + * @returns 创建的附件信息 + */ + async addLink( + taskId: string, + url: string, + fileName?: string + ): Promise> { + const response = await apiClient.post>( + '/attachment/addLink', + { taskId, url, fileName } + ); + + if (response.data && 'success' in response.data) { + return response.data as ApiResponse; + } + return { success: true, data: response.data as AttachmentItem, message: '链接已添加' }; + }, + + /** + * 获取待办项的附件列表。 + * @param taskId 待办项 ID + * @returns 附件列表 + */ + async list(taskId: string): Promise> { + const response = await apiClient.get>( + `/attachment?taskId=${taskId}` + ); + + if (Array.isArray(response.data)) { + return { success: true, data: response.data, message: '获取成功' }; + } + return response.data as ApiResponse; + }, + + /** + * 删除附件。 + * @param attachmentId 附件 ID + * @returns 操作结果 + */ + async remove(attachmentId: string): Promise> { + const response = await apiClient.delete>( + `/attachment?id=${attachmentId}` + ); + + if (response.data && 'success' in response.data) { + return response.data; + } + return { success: true, message: '已删除' }; + }, + + /** + * 打开附件(系统关联程序或浏览器)。 + * @param attachmentId 附件 ID + * @returns 打开结果 + */ + async open(attachmentId: string): Promise> { + const response = await apiClient.post<{ opened: boolean } | ApiResponse<{ opened: boolean }>>( + `/attachment/open?id=${attachmentId}` + ); + + if (response.data && 'success' in response.data) { + return response.data as ApiResponse<{ opened: boolean }>; + } + return { success: true, data: response.data as { opened: boolean }, message: '已打开' }; + }, + + /** + * 下载附件。 + * Base64 方式:通过后端 API 获取文件 Blob 下载。 + * 注意:DynamicApi 可能不支持文件流返回,此处返回附件信息供前端使用。 + * @param attachmentId 附件 ID + * @returns 下载 URL 或附件信息 + */ + async download(attachmentId: string): Promise> { + const response = await apiClient.get>( + `/attachment?taskId=0&id=${attachmentId}` + ); + + if (response.data && 'success' in response.data) { + return response.data as ApiResponse; + } + return { success: true, data: response.data as AttachmentItem, message: '获取成功' }; + } +}; diff --git a/src/Hua.Todo.Web/src/api/cloudClient.ts b/src/Hua.Todo.Web/src/api/cloudClient.ts index b334c5f..47dfe66 100644 --- a/src/Hua.Todo.Web/src/api/cloudClient.ts +++ b/src/Hua.Todo.Web/src/api/cloudClient.ts @@ -21,8 +21,8 @@ const requestCloudSyncReLogin = (): void => { * 云同步专用 HTTP Client。 * * 注意:本项目现有 `client.ts` 的 baseURL 固定为 `window.__API_BASE_URL__`(通常已包含 `/api`), - * 但云同步服务端端点位于根路径(`/auth/*`、`/tasks/*` 等)。 - * 因此云同步需要独立的 baseURL(用户配置的服务端地址)。 + * 云同步服务端端点同样位于 `/api/` 路径下(`/api/auth/*`、`/api/tasks/*` 等)。 + * 云同步使用独立的 axios 实例(不设 baseURL),通过 Vite proxy 或嵌入式代理转发到后端。 */ const cloudClient = axios.create({ timeout: 10000, diff --git a/src/Hua.Todo.Web/src/api/cloudSync.ts b/src/Hua.Todo.Web/src/api/cloudSync.ts index c80abe8..537a90d 100644 --- a/src/Hua.Todo.Web/src/api/cloudSync.ts +++ b/src/Hua.Todo.Web/src/api/cloudSync.ts @@ -94,6 +94,36 @@ export interface ProbeServerUrlResponse { type: 'success' | 'warn' | 'error'; } +/** + * DynamicApi 统一响应包裹结构。 + */ +interface DynamicApiResponse { + success: boolean; + data: T; + message?: string; + errors?: string[] | null; +} + +/** + * 判断响应是否为 DynamicApi 包裹格式。 + */ +function isDynamicApiResponse(value: unknown): value is DynamicApiResponse { + return Boolean(value && typeof value === 'object' && 'success' in value && 'data' in value); +} + +/** + * 解包 DynamicApi 响应;兼容历史裸 DTO 响应,避免云同步接口迁移后前端读取不到真正 data。 + */ +function unwrapDynamicApiResponse(value: T | DynamicApiResponse): T { + if (isDynamicApiResponse(value)) { + if (!value.success) { + throw new Error(value.errors?.join('\n') || value.message || '云同步请求失败'); + } + return value.data; + } + return value; +} + /** * 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。 */ @@ -106,7 +136,9 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { priority: item.priority ?? 1, isCompleted: Boolean(item.isCompleted), code: item.code ?? '', - parentTaskId: item.parentTaskId, + parentTaskId: item.parentTaskId ?? undefined, + createdAt: item.creationTime, + updatedAt: item.lastModificationTime ?? item.creationTime, creationTime: item.creationTime, creatorId: item.creatorId, lastModificationTime: item.lastModificationTime, @@ -181,8 +213,12 @@ export const cloudSyncApi = { * 登录云同步服务端并保存会话(默认存入 sessionStorage)。 */ async login(request: CloudLoginRequest): Promise { - const response = await cloudClient.post('/auth/login', request); - const session = response.data; + const response = await cloudClient.post>('/api/auth/login', request); + const session = unwrapDynamicApiResponse(response.data); + + if (!session) { + throw new Error('登录失败:服务端返回空响应'); + } CloudSyncStorage.saveSession({ accessToken: session.accessToken, @@ -200,7 +236,7 @@ export const cloudSyncApi = { */ async logout(): Promise { try { - await cloudClient.post('/auth/logout'); + await cloudClient.post('/api/auth/logout'); } catch { // 即使服务端请求失败也清空本地,避免残留无效 Token } @@ -211,24 +247,26 @@ export const cloudSyncApi = { * 获取云端任务全量,并转换为前端树结构。 */ async getTasks(): Promise { - const response = await cloudClient.get('/tasks/'); - const items = Array.isArray(response.data) ? response.data : []; + const response = await cloudClient.get>('/api/tasks/'); + const data = unwrapDynamicApiResponse(response.data); + const items = Array.isArray(data) ? data : []; return buildTaskTreeFromCloudItems(items); }, /** * 同步本地任务到云端(批量 upsert + delete),并返回云端最新任务列表和服务器时间。 - * 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。 + * 调用路径:POST /api/tasks ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。 * 注意:该端点需要已登录 + 二次认证(step-up)。 */ async syncTasks(localTasks: Task[], deleteIds: string[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> { const upserts = flattenTasksToCloudUpserts(localTasks); const body: CloudSyncRequest = { upserts, deletes: deleteIds }; - const response = await cloudClient.post('/sync/', body); - const items = Array.isArray(response.data?.tasks) ? response.data.tasks : []; + const response = await cloudClient.post>('/api/tasks', body); + const data = unwrapDynamicApiResponse(response.data); + const items = Array.isArray(data?.tasks) ? data.tasks : []; return { tasks: buildTaskTreeFromCloudItems(items), - serverTimeUtc: response.data?.serverTimeUtc, + serverTimeUtc: data?.serverTimeUtc, }; }, @@ -236,8 +274,8 @@ export const cloudSyncApi = { * 获取当前用户的安全策略。 */ async getSecurityPolicy(): Promise { - const response = await cloudClient.get('/security/policy'); - return response.data; + const response = await cloudClient.get>('/api/security/policy'); + return unwrapDynamicApiResponse(response.data); }, /** @@ -245,8 +283,8 @@ export const cloudSyncApi = { * 探测请求从 Host 发起,避免浏览器直连的 CORS/证书/网络限制。 */ async probeServerUrl(targetUrl: string): Promise { - const response = await cloudClient.post('/cloud-sync/probe', { targetUrl }); - return response.data; + const response = await cloudClient.post>('/api/cloud-sync/probe', { targetUrl }); + return unwrapDynamicApiResponse(response.data); }, }; diff --git a/src/Hua.Todo.Web/src/api/meeting.ts b/src/Hua.Todo.Web/src/api/meeting.ts new file mode 100644 index 0000000..7af1bc2 --- /dev/null +++ b/src/Hua.Todo.Web/src/api/meeting.ts @@ -0,0 +1,105 @@ +import apiClient from './client' +import type { TaskPriority } from '../types/task' + +/// 转写响应 +export interface TranscribeResponse { + taskId: string + transcript: string + audioDuration: number +} + +/// 会议详情 +export interface MeetingDetail { + taskId: string + title: string + meetingNotes?: string + audioDuration?: number + isCompleted: boolean +} + +/// 会议任务拆分建议 +export interface MeetingTaskSuggestion { + title: string + priority: TaskPriority + reason: string +} + +/// 拆分响应 +export interface BreakdownResponse { + taskId: string + suggestions: MeetingTaskSuggestion[] +} + +/// 待确认的子任务项 +export interface SubTaskCreateItem { + title: string + priority: TaskPriority +} + +/// 批量创建子任务响应 +export interface ConfirmBreakdownResponse { + taskId: string + createdCount: number + subTaskIds: string[] +} + +// ========== 已有方法 ========== + +/// 上传音频文件并请求转写,音频以 Base64 编码在 JSON 请求体中传输 +/// 待办项 ID +/// Base64 编码的音频数据 +/// 音频格式,如 "webm" +/// 音频时长(秒) +export async function transcribeAudio( + taskId: string, + audioBase64: string, + format: string, + audioDuration: number +): Promise { + const resp = await apiClient.post('/meeting/transcribeAudio', { + taskId, + audioBase64, + format, + audioDuration + }) + return resp.data +} + +/// 保存/更新会议纪要 +/// 待办项 ID +/// 纪要内容 +export async function saveMeetingNotes(taskId: string, notes: string): Promise { + await apiClient.post('/meeting/saveNotes', { taskId, notes }) +} + +/// 获取会议详情 +/// 待办项 ID +export async function getMeetingDetail(taskId: string): Promise { + const resp = await apiClient.get('/meeting', { params: { taskId } }) + return resp.data +} + +// ========== 拆分与确认方法 ========== + +/// 请求 AI 拆分会议内容为待办项建议 +/// 会议待办项 ID +/// 拆分响应,包含建议列表 +export async function requestBreakdown(taskId: string): Promise { + const resp = await apiClient.post('/meeting/breakdown', { taskId }) + return resp.data +} + +/// 确认并批量创建选中的子任务 +/// 会议待办项 ID +/// 用户确认的子任务列表 +/// 创建结果,包含创建数量和子任务 ID 列表 +export async function confirmBreakdown( + taskId: string, + subTasks: SubTaskCreateItem[] +): Promise { + const resp = await apiClient.post('/meeting/confirmBreakdown', { + taskId, + subTasks + }) + return resp.data +} diff --git a/src/Hua.Todo.Web/src/api/tasks.ts b/src/Hua.Todo.Web/src/api/tasks.ts index 2c7cbc6..c6fafb0 100644 --- a/src/Hua.Todo.Web/src/api/tasks.ts +++ b/src/Hua.Todo.Web/src/api/tasks.ts @@ -60,7 +60,7 @@ export const taskApi = { * @param id 任务 ID * @returns 包含任务对象或 null 的响应对象 */ - async getTask(id: number): Promise> { + async getTask(id: string): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const task = localTasks.find(t => t.id === id); @@ -100,13 +100,15 @@ export const taskApi = { */ async createTask(dto: CreateTaskDto): Promise> { const newTask: Task = { - id: Date.now(), + id: crypto.randomUUID(), title: dto.title, priority: dto.priority, isCompleted: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), parentTaskId: dto.parentTaskId, + taskType: dto.taskType ?? 0, + description: dto.description, subTasks: [] }; @@ -162,7 +164,7 @@ export const taskApi = { * @param dto 更新任务的数据传输对象 * @returns 包含更新后任务的响应对象 */ - async updateTask(id: number, dto: UpdateTaskDto): Promise> { + async updateTask(id: string, dto: UpdateTaskDto): Promise> { const updateDto = dto; if (!LocalStorageService.isOnline()) { @@ -176,6 +178,12 @@ export const taskApi = { if (dto.priority !== undefined) { localTasks[taskIndex].priority = dto.priority; } + if (dto.description !== undefined) { + localTasks[taskIndex].description = dto.description; + } + if (dto.taskType !== undefined) { + localTasks[taskIndex].taskType = dto.taskType; + } localTasks[taskIndex].updatedAt = new Date().toISOString(); LocalStorageService.saveTasks(localTasks); @@ -229,7 +237,7 @@ export const taskApi = { * @param id 任务 ID * @returns 包含更新后任务的响应对象 */ - async toggleComplete(id: number): Promise> { + async toggleComplete(id: string): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const taskIndex = localTasks.findIndex(t => t.id === id); @@ -289,7 +297,7 @@ export const taskApi = { * @param id 任务 ID * @returns 包含操作结果的响应对象 */ - async deleteTask(id: number): Promise> { + async deleteTask(id: string): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const taskIndex = localTasks.findIndex(t => t.id === id); diff --git a/src/Hua.Todo.Web/src/api/voice.ts b/src/Hua.Todo.Web/src/api/voice.ts new file mode 100644 index 0000000..306e882 --- /dev/null +++ b/src/Hua.Todo.Web/src/api/voice.ts @@ -0,0 +1,125 @@ +import apiClient from './client'; +import type { Task, TaskPriority } from '../types/task'; + +// ========== 请求/响应类型 ========== + +export interface VoiceCommandRequest { + text: string; +} + +export interface VoiceCommandConfirmRequest { + intent: string; + targetId: string; + newTitle?: string; +} + +export interface VoiceCandidate { + id: string; + title: string; +} + +export interface VoiceExecutionResult { + success: boolean; + message: string; + ambiguity: boolean; + candidates: VoiceCandidate[]; +} + +export interface VoiceCommandResponse { + intent: string; + params: Record; + confidence: number; + result: VoiceExecutionResult; +} + +export interface AiBreakdownSuggestion { + title: string; + priority: TaskPriority; + reason: string; +} + +export interface AiBreakdownResponse { + taskId: string; + suggestions: AiBreakdownSuggestion[]; +} + +export interface SubTaskItem { + title: string; + priority: TaskPriority; +} + +/** + * 语音控制 API 模块。 + * 提供语音指令解析执行、歧义确认和 AI 拆分端点。 + */ +export const voiceApi = { + /** + * 解析并执行语音指令。 + * POST /api/voice/command + * @param text STT 识别后的原始文字 + * @returns 解析执行结果 + */ + async executeCommand(text: string): Promise { + const resp = await apiClient.post( + '/voice/command', + { text } + ); + return resp.data; + }, + + /** + * 确认歧义指令并执行。 + * POST /api/voice/command/confirm + * @param intent 上次解析出的意图 + * @param targetId 用户选择的任务 ID + * @param newTitle 可选:修改后的标题 + * @returns 执行结果 + */ + async confirmCommand( + intent: string, + targetId: string, + newTitle?: string + ): Promise { + const resp = await apiClient.post( + '/voice/command/confirm', + { intent, targetId, newTitle } + ); + return resp.data; + }, + + /** + * 获取 AI 任务拆分建议。 + * POST /api/voice/ai-breakdown + * @param taskId 目标任务 ID + * @returns AI 拆分建议列表 + */ + async getBreakdown(taskId: string): Promise { + const resp = await apiClient.post( + '/voice/ai-breakdown', + { taskId } + ); + return resp.data; + }, + + /** + * 确认 AI 拆分建议并批量创建子任务。 + * POST /api/voice/ai-breakdown/confirm + * @param parentTaskId 父任务 ID + * @param subTasks 用户确认的子任务列表 + * @returns 创建后的子任务列表 + */ + async confirmBreakdown( + parentTaskId: string, + subTasks: SubTaskItem[] + ): Promise<{ success: boolean; data: Task[]; message: string }> { + const resp = await apiClient.post( + '/voice/ai-breakdown/confirm', + { parentTaskId, subTasks } + ); + return { + success: true, + data: resp.data, + message: '创建成功', + }; + }, +}; diff --git a/src/Hua.Todo.Web/src/components/AttachmentList.vue b/src/Hua.Todo.Web/src/components/AttachmentList.vue new file mode 100644 index 0000000..f6b2728 --- /dev/null +++ b/src/Hua.Todo.Web/src/components/AttachmentList.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/Hua.Todo.Web/src/components/AudioRecorder.vue b/src/Hua.Todo.Web/src/components/AudioRecorder.vue new file mode 100644 index 0000000..9383722 --- /dev/null +++ b/src/Hua.Todo.Web/src/components/AudioRecorder.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue index 5a2e224..ba92e66 100644 --- a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue +++ b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue @@ -224,21 +224,22 @@ const canSync = computed(() => { const loadLocalState = async () => { refreshSession(); - // 从嵌入式 WebServer 的本地 API 获取服务端地址 + // 从嵌入式 WebServer 的本地 API(DynamicApi)获取服务端地址 try { - const res = await axios.get('/api/cloud-sync/settings'); - const serverUrl = (res.data as any)?.serverUrl ?? ''; + const res = await axios.get('/api/cloudSyncProxySettings'); + const serverUrl = (res.data as any)?.data?.serverUrl ?? ''; serverUrlSaved.value = serverUrl; - serverUrlInput.value = serverUrl; + serverUrlInput.value = serverUrl || 'http://localhost:5173'; } catch { // 嵌入式服务器未就绪或接口不存在,回退到 localStorage const settings = CloudSyncStorage.loadSettings(); serverUrlSaved.value = settings.serverUrl; - serverUrlInput.value = settings.serverUrl; + serverUrlInput.value = settings.serverUrl || 'http://localhost:5173'; } - // 启用状态保持 localStorage(客户端偏好) - localEnabled.value = CloudSyncStorage.loadSettings().enabled; + // 启用状态保持 localStorage(客户端偏好),默认启用 + const settings = CloudSyncStorage.loadSettings(); + localEnabled.value = settings.enabled !== false; refreshSyncStatus(); if (isLoggedIn.value) { @@ -247,20 +248,41 @@ const loadLocalState = async () => { }; /** - * 刷新安全策略 - * 从服务端获取最新的安全策略,并同步到 cloudSyncState 和 LocalStorageService - * - * @throws 获取失败时输出错误日志,不中断流程 + * 刷新安全策略。 + * + * 401 表示当前会话不可继续信任,可能是本地 Token 失效,也可能是远端策略接口拒绝。 + * 此时必须清理本地会话和策略缓存,并把刷新结果返回给调用方,避免登录流程误判为成功。 + * + * @returns 是否成功获取安全策略。 */ -const refreshPolicy = async () => { +const refreshPolicy = async (): Promise => { + if (!CloudSyncStorage.loadSession()?.accessToken) { + policy.value = null; + cloudSyncState.policy = { allowPersist: true, allowSync: true }; + LocalStorageService.setAllowPersist(true); + return false; + } + try { const p = await cloudSyncApi.getSecurityPolicy(); policy.value = p; cloudSyncState.policy = p; // 更新落盘策略:控制是否允许本地持久化存储 LocalStorageService.setAllowPersist(p.allowPersist); + return true; } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 401) { + CloudSyncStorage.clearSession(); + refreshSession(); + policy.value = null; + cloudSyncState.policy = { allowPersist: true, allowSync: true }; + LocalStorageService.setAllowPersist(true); + emitCloudSyncStateChanged(); + return false; + } + console.error('Failed to fetch security policy:', err); + return false; } }; @@ -427,7 +449,7 @@ const saveServerUrl = async () => { // 写入嵌入式 WebServer 的本地 API(持久化到 appsettings.json) try { - await axios.post('/api/cloud-sync/settings', { serverUrl: serverUrlSaved.value }); + await axios.post('/api/cloudSyncProxySettings', { serverUrl: serverUrlSaved.value }); } catch { // 回退到 localStorage CloudSyncStorage.saveSettings({ @@ -478,9 +500,15 @@ const login = async () => { userName: userName.value.trim(), password: password.value, }); - showToast('登录成功', 'success'); refreshSession(); - await refreshPolicy(); + + const policyLoaded = await refreshPolicy(); + if (!policyLoaded) { + showToast('登录失败:无法获取安全策略,请检查服务端认证配置', 'error'); + return; + } + + showToast('登录成功', 'success'); emitCloudSyncStateChanged(); } finally { isLoggingIn.value = false; @@ -533,6 +561,11 @@ const syncNow = async () => { function handleOpenSettings() { loadLocalState(); probeResult.value = null; + // 默认填充登录凭据(如果未登录) + if (!isLoggedIn.value) { + if (!userName.value) userName.value = 'admin'; + if (!password.value) password.value = '123456'; + } isOpen.value = true; } diff --git a/src/Hua.Todo.Web/src/components/HelloWorld.vue b/src/Hua.Todo.Web/src/components/HelloWorld.vue deleted file mode 100644 index 3a7b808..0000000 --- a/src/Hua.Todo.Web/src/components/HelloWorld.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/src/Hua.Todo.Web/src/components/LinkInputDialog.vue b/src/Hua.Todo.Web/src/components/LinkInputDialog.vue new file mode 100644 index 0000000..0940c7c --- /dev/null +++ b/src/Hua.Todo.Web/src/components/LinkInputDialog.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/Hua.Todo.Web/src/components/MeetingBreakdownDialog.vue b/src/Hua.Todo.Web/src/components/MeetingBreakdownDialog.vue new file mode 100644 index 0000000..95de72e --- /dev/null +++ b/src/Hua.Todo.Web/src/components/MeetingBreakdownDialog.vue @@ -0,0 +1,523 @@ + + + + + diff --git a/src/Hua.Todo.Web/src/components/TaskEditDialog.vue b/src/Hua.Todo.Web/src/components/TaskEditDialog.vue index e3aefdf..3ee250c 100644 --- a/src/Hua.Todo.Web/src/components/TaskEditDialog.vue +++ b/src/Hua.Todo.Web/src/components/TaskEditDialog.vue @@ -2,12 +2,12 @@
-

编辑任务

+

编辑待办项

- +
+
+ + + {{ taskDescription.length }}/5000 +
+ + + +
{{ attach.error }}
+ + +
+ + +
@@ -572,6 +873,92 @@ onUnmounted(() => { color: #475569; } +.info-memory { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-color: #f59e0b; + color: #92400e; + font-weight: 600; +} + +/* ========== 云同步按钮样式 ========== */ +.info-login-btn { + cursor: pointer; + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + border-color: #93c5fd; + color: #1d4ed8; + font-weight: 600; + transition: all 0.2s; +} + +.info-login-btn:hover { + background: linear-gradient(135deg, #bfdbfe 0%, #93c5fd 100%); + border-color: #60a5fa; +} + +.info-user { + background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); + border-color: #c4b5fd; + color: #6d28d9; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.info-sync-btn { + cursor: pointer; + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + border-color: #6ee7b7; + font-weight: 600; + transition: all 0.2s; + padding: 0 6px; + min-width: 24px; + justify-content: center; +} + +.info-sync-btn:hover:enabled { + background: linear-gradient(135deg, #a7f3d0 0%, #6ee7b7 100%); + border-color: #34d399; +} + +.info-sync-btn.syncing { + animation: syncSpin 1s linear infinite; +} + +@keyframes syncSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.info-settings-btn { + cursor: pointer; + background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%); + border-color: #d1d5db; + color: #6b7280; + font-weight: 600; + padding: 0 6px; + min-width: 20px; + justify-content: center; + transition: all 0.2s; +} + +.info-settings-btn:hover { + background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%); + color: #374151; +} + +.info-logout-btn { + cursor: pointer; + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); + border-color: #fca5a5; + color: #dc2626; + font-weight: 600; + transition: all 0.2s; +} + +.info-logout-btn:hover { + background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%); + border-color: #f87171; +} + .info-item svg { flex-shrink: 0; } @@ -657,6 +1044,89 @@ onUnmounted(() => { transform: translateY(0); } +/* ========== 语音按钮 ========== */ +.voice-btn { + background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%) !important; +} + +.voice-pulse { + animation: voicePulse 1.2s ease-in-out infinite; + font-size: 12px; +} + +@keyframes voicePulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ========== 语音状态指示 ========== */ +.voice-indicator { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 4px 8px; + margin-top: 4px; + background: rgba(139, 92, 246, 0.08); + border-radius: 8px; + border: 1px solid rgba(139, 92, 246, 0.15); +} + +.voice-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ef4444; + animation: voicePulse 0.8s ease-in-out infinite; + flex-shrink: 0; +} + +.voice-text { + font-size: 12px; + color: #475569; + flex: 1; +} + +.voice-error { + font-size: 12px; + color: #ef4444; +} + +/* ========== 歧义候选 ========== */ +.voice-candidates { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid rgba(139, 92, 246, 0.1); +} + +.candidates-hint { + font-size: 11px; + color: #6b7280; + margin-right: 4px; +} + +.candidate-btn { + padding: 2px 10px; + border: 1px solid #c4b5fd; + border-radius: 12px; + background: white; + color: #7c3aed; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.candidate-btn:hover { + background: #7c3aed; + color: white; + border-color: #7c3aed; +} + .hotkey-settings-btn svg { width: 14px; height: 14px; diff --git a/src/Hua.Todo.Web/src/composables/useAttachments.ts b/src/Hua.Todo.Web/src/composables/useAttachments.ts new file mode 100644 index 0000000..58c6901 --- /dev/null +++ b/src/Hua.Todo.Web/src/composables/useAttachments.ts @@ -0,0 +1,193 @@ +import { ref } from 'vue'; +import { attachmentApi } from '../api/attachments'; +import type { AttachmentItem, Task } from '../types/task'; + +/** 附件缺失标记 */ +export interface AttachmentWithStatus extends AttachmentItem { + isMissing?: boolean; +} + +/** + * 附件管理组合式函数。 + * 封装附件上传、列表加载、删除、打开和外部链接添加逻辑。 + */ +export function useAttachments() { + const attachments = ref([]); + const isLoading = ref(false); + const error = ref(''); + + /** + * 加载指定待办项的附件列表。 + * @param taskId 待办项 ID + */ + async function loadAttachments(taskId: string) { + isLoading.value = true; + error.value = ''; + try { + const resp = await attachmentApi.list(taskId); + if (resp.success && resp.data) { + attachments.value = resp.data.map(a => ({ ...a, isMissing: false })); + } + } catch (err: any) { + error.value = err?.message || '加载附件失败'; + console.error('loadAttachments failed:', err); + } finally { + isLoading.value = false; + } + } + + /** + * 上传文件附件。 + * 将 File 对象转为 Base64 后上传。 + * @param taskId 待办项 ID + * @param file 浏览器 File 对象 + */ + async function uploadFile(taskId: string, file: File): Promise { + if (file.size > 50 * 1024 * 1024) { + error.value = '文件大小不能超过 50MB'; + return false; + } + + if (attachments.value.length >= 20) { + error.value = '每个待办项最多 20 个附件'; + return false; + } + + isLoading.value = true; + error.value = ''; + try { + const base64 = await fileToBase64(file); + const resp = await attachmentApi.upload(taskId, file.name, base64, file.type); + if (resp.success && resp.data) { + attachments.value.push({ ...resp.data, isMissing: false }); + return true; + } + error.value = resp.message || '上传失败'; + return false; + } catch (err: any) { + error.value = err?.message || '上传失败'; + console.error('uploadFile failed:', err); + return false; + } finally { + isLoading.value = false; + } + } + + /** + * 添加外部链接附件。 + * @param taskId 待办项 ID + * @param url 链接 URL + * @param fileName 显示名称 + */ + async function addLinkAttachment(taskId: string, url: string, fileName?: string): Promise { + if (attachments.value.length >= 20) { + error.value = '每个待办项最多 20 个附件'; + return false; + } + + isLoading.value = true; + error.value = ''; + try { + const resp = await attachmentApi.addLink(taskId, url, fileName); + if (resp.success && resp.data) { + attachments.value.push({ ...resp.data, isMissing: false }); + return true; + } + error.value = resp.message || '添加链接失败'; + return false; + } catch (err: any) { + error.value = err?.message || '添加链接失败'; + console.error('addLinkAttachment failed:', err); + return false; + } finally { + isLoading.value = false; + } + } + + /** + * 删除附件。 + * @param attachmentId 附件 ID + */ + async function removeAttachment(attachmentId: string): Promise { + isLoading.value = true; + error.value = ''; + try { + const resp = await attachmentApi.remove(attachmentId); + if (resp.success) { + attachments.value = attachments.value.filter(a => a.id !== attachmentId); + return true; + } + error.value = resp.message || '删除失败'; + return false; + } catch (err: any) { + error.value = err?.message || '删除失败'; + console.error('removeAttachment failed:', err); + return false; + } finally { + isLoading.value = false; + } + } + + /** + * 打开附件(系统关联程序或浏览器)。 + * @param attachmentId 附件 ID + */ + async function openAttachment(attachmentId: string): Promise { + try { + const resp = await attachmentApi.open(attachmentId); + if (resp.success) return true; + error.value = resp.message || '打开失败'; + return false; + } catch (err: any) { + error.value = err?.message || '打开失败'; + console.error('openAttachment failed:', err); + return false; + } + } + + /** + * 从 Task 中提取附件数据初始化。 + * @param task 待办项 + */ + function initFromTask(task: Task) { + if (task.attachments) { + attachments.value = task.attachments.map(a => ({ ...a, isMissing: false })); + return; + } + // 仅在有 attachmentCount 但没有 attachments 时异步加载 + if (task.attachmentCount !== undefined && task.attachmentCount > 0) { + loadAttachments(task.id); + } else { + attachments.value = []; + } + } + + return { + attachments, + isLoading, + error, + loadAttachments, + uploadFile, + addLinkAttachment, + removeAttachment, + openAttachment, + initFromTask, + }; +} + +/** + * 将 File 对象转换为 Base64 字符串。 + */ +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // 移除 data:*;base64, 前缀 + const base64 = result.split(',')[1] || result; + resolve(base64); + }; + reader.onerror = () => reject(new Error('读取文件失败')); + reader.readAsDataURL(file); + }); +} diff --git a/src/Hua.Todo.Web/src/composables/useAudioRecorder.ts b/src/Hua.Todo.Web/src/composables/useAudioRecorder.ts new file mode 100644 index 0000000..1f6048f --- /dev/null +++ b/src/Hua.Todo.Web/src/composables/useAudioRecorder.ts @@ -0,0 +1,161 @@ +import { ref, onUnmounted } from 'vue' + +/// 音频录制器组合式函数,封装 MediaRecorder API +export function useAudioRecorder() { + const isRecording = ref(false) + const isPaused = ref(false) + const duration = ref(0) // 秒 + const audioBlob = ref(null) + const audioUrl = ref(null) + const error = ref(null) + const isSupported = ref(typeof MediaRecorder !== 'undefined') + + const MAX_DURATION = 7200 // 最长 2 小时(秒) + + let mediaRecorder: MediaRecorder | null = null + let timerInterval: ReturnType | null = null + let chunks: Blob[] = [] + + /** 获取当前浏览器支持的 MIME 类型 */ + function getMimeType(): string { + const types = ['audio/webm', 'audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus'] + for (const t of types) { + if (MediaRecorder.isTypeSupported(t)) { + return t + } + } + return 'audio/webm' // fallback + } + + /** 获取文件扩展名(不含点) */ + function getFormat(): string { + const mime = getMimeType() + if (mime.includes('mp4')) return 'mp4' + if (mime.includes('ogg')) return 'ogg' + return 'webm' + } + + /// 请求麦克风权限,开始录制 + async function startRecording(): Promise { + error.value = null + if (!isSupported.value) { + error.value = '当前浏览器不支持录音,请使用文字输入' + return + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const mimeType = getMimeType() + mediaRecorder = new MediaRecorder(stream, { mimeType }) + chunks = [] + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunks.push(e.data) + } + } + + mediaRecorder.onstop = () => { + // 停止所有轨道释放麦克风 + stream.getTracks().forEach((t) => t.stop()) + const mime = mediaRecorder?.mimeType || mimeType + audioBlob.value = new Blob(chunks, { type: mime }) + if (audioUrl.value) { + URL.revokeObjectURL(audioUrl.value) + } + audioUrl.value = URL.createObjectURL(audioBlob.value) + } + + mediaRecorder.onerror = () => { + error.value = '录音过程中发生错误' + stopTimer() + isRecording.value = false + } + + mediaRecorder.start() + isRecording.value = true + startTimer() + } catch (err: any) { + if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { + error.value = '无法访问麦克风,请使用文字输入' + } else { + error.value = '无法启动录音:' + (err.message || '未知错误') + } + } + } + + /// 停止录制,生成 Blob + function stopRecording(): void { + if (!mediaRecorder || mediaRecorder.state === 'inactive') return + + stopTimer() + mediaRecorder.stop() + isRecording.value = false + } + + /// 重置状态,清除录制结果 + function resetRecording(): void { + stopTimer() + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop() + } + isRecording.value = false + isPaused.value = false + duration.value = 0 + if (audioUrl.value) { + URL.revokeObjectURL(audioUrl.value) + } + audioBlob.value = null + audioUrl.value = null + error.value = null + chunks = [] + } + + /// 获取录制结果的 Blob + function getAudioBlob(): Blob | null { + return audioBlob.value + } + + function startTimer(): void { + stopTimer() + duration.value = 0 + timerInterval = setInterval(() => { + duration.value++ + if (duration.value >= MAX_DURATION) { + stopRecording() + } + }, 1000) + } + + function stopTimer(): void { + if (timerInterval !== null) { + clearInterval(timerInterval) + timerInterval = null + } + } + + onUnmounted(() => { + stopTimer() + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop() + } + if (audioUrl.value) { + URL.revokeObjectURL(audioUrl.value) + } + }) + + return { + isRecording, + isPaused, + duration, + audioBlob, + audioUrl, + error, + isSupported, + getFormat, + startRecording, + stopRecording, + resetRecording, + getAudioBlob + } +} diff --git a/src/Hua.Todo.Web/src/composables/useVoiceInput.ts b/src/Hua.Todo.Web/src/composables/useVoiceInput.ts new file mode 100644 index 0000000..92f27d8 --- /dev/null +++ b/src/Hua.Todo.Web/src/composables/useVoiceInput.ts @@ -0,0 +1,146 @@ +import { ref, computed } from 'vue'; +import { voiceApi } from '../api/voice'; +import type { VoiceCommandResponse } from '../api/voice'; + +/** + * 语音输入组合式函数。 + * 封装浏览器 Web Speech API 的语音识别和语音播报能力。 + */ +export function useVoiceInput() { + const isListening = ref(false); + const recognizedText = ref(''); + const error = ref(''); + const isExecuting = ref(false); + const lastResult = ref(null); + + /** 浏览器是否支持语音识别 */ + const isSupported = computed(() => { + return !!(window as any).SpeechRecognition || !!(window as any).webkitSpeechRecognition; + }); + + let recognition: any = null; + + /** + * 开始语音监听,识别结果自动发送到后端执行。 + * 完成后通过 onResult 回调返回执行结果。 + */ + async function startListening( + onResult?: (response: VoiceCommandResponse) => void + ): Promise { + if (!isSupported.value) { + error.value = '当前浏览器不支持语音识别'; + return; + } + + if (isListening.value) return; + + const SpeechRecognition = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + recognition = new SpeechRecognition(); + recognition.lang = 'zh-CN'; + recognition.interimResults = false; + recognition.maxAlternatives = 1; + recognition.continuous = false; + + recognition.onresult = async (event: any) => { + const transcript = event.results[0][0].transcript; + recognizedText.value = transcript; + + if (!transcript.trim()) return; + + isExecuting.value = true; + error.value = ''; + + try { + const response = await voiceApi.executeCommand(transcript); + lastResult.value = response; + onResult?.(response); + + // TTS 播报结果 + speakText(response.result.message); + } catch (err: any) { + error.value = err?.message || '语音指令执行失败'; + speakText('指令执行失败'); + } finally { + isExecuting.value = false; + } + }; + + recognition.onerror = (event: any) => { + error.value = `语音识别错误: ${event.error}`; + isListening.value = false; + }; + + recognition.onend = () => { + isListening.value = false; + }; + + try { + await recognition.start(); + isListening.value = true; + } catch (err: any) { + error.value = err?.message || '启动语音识别失败'; + isListening.value = false; + } + } + + /** + * 停止语音监听。 + */ + function stopListening(): void { + if (recognition) { + recognition.stop(); + } + isListening.value = false; + } + + /** + * 确认歧义指令并执行。 + */ + async function confirmAmbiguity( + intent: string, + targetId: string, + newTitle?: string + ): Promise { + isExecuting.value = true; + error.value = ''; + try { + const response = await voiceApi.confirmCommand(intent, targetId, newTitle); + lastResult.value = response; + speakText(response.result.message); + return response; + } catch (err: any) { + error.value = err?.message || '确认执行失败'; + return null; + } finally { + isExecuting.value = false; + } + } + + /** + * TTS 语音播报(使用浏览器 Web Speech API)。 + */ + function speakText(text: string): void { + if (!text || !('speechSynthesis' in window)) return; + try { + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'zh-CN'; + utterance.rate = 1.0; + window.speechSynthesis.speak(utterance); + } catch { + // TTS 失败静默处理 + } + } + + return { + isListening, + isExecuting, + recognizedText, + error, + lastResult, + isSupported, + startListening, + stopListening, + confirmAmbiguity, + }; +} diff --git a/src/Hua.Todo.Web/src/services/localStorageService.ts b/src/Hua.Todo.Web/src/services/localStorageService.ts index 28142d7..b561e43 100644 --- a/src/Hua.Todo.Web/src/services/localStorageService.ts +++ b/src/Hua.Todo.Web/src/services/localStorageService.ts @@ -4,13 +4,26 @@ import { normalizeTasks } from './taskNormalizer'; const STORAGE_KEY = 'Hua.Todo_tasks'; const SYNC_STATUS_KEY = 'Hua.Todo_sync_status'; +/** + * 记录前端本地同步状态,用于展示最近同步时间、网络状态和待同步变更数量。 + */ export interface SyncStatus { + /** 最近一次同步完成的时间戳,0 表示尚未同步。 */ lastSyncTime: number; + /** 当前记录的在线状态,实际网络状态以 navigator.onLine 为准。 */ isOnline: boolean; + /** 尚未成功同步到远端的本地变更数量。 */ pendingChanges: number; } +/** + * 封装 Todo 待办项与同步状态的 localStorage 读写,统一处理序列化、反序列化和异常兜底。 + */ export class LocalStorageService { + /** + * 将 Todo 待办项列表标准化后保存到 localStorage。 + * @param tasks 待保存的 Todo 待办项列表。 + */ static saveTasks(tasks: Task[]): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeTasks(tasks))); @@ -19,6 +32,10 @@ export class LocalStorageService { } } + /** + * 从 localStorage 读取 Todo 待办项列表,并在返回前执行标准化。 + * @returns 已标准化的 Todo 待办项列表;读取失败或无数据时返回空数组。 + */ static loadTasks(): Task[] { try { const data = localStorage.getItem(STORAGE_KEY); @@ -29,6 +46,9 @@ export class LocalStorageService { } } + /** + * 清空 localStorage 中持久化的 Todo 待办项数据。 + */ static clearTasks(): void { try { localStorage.removeItem(STORAGE_KEY); @@ -37,6 +57,10 @@ export class LocalStorageService { } } + /** + * 保存当前同步状态,供 UI 在下次启动时恢复展示。 + * @param status 要持久化的同步状态。 + */ static saveSyncStatus(status: SyncStatus): void { try { localStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status)); @@ -45,6 +69,10 @@ export class LocalStorageService { } } + /** + * 从 localStorage 读取同步状态。 + * @returns 已保存的同步状态;无数据或读取失败时返回默认在线且无待同步变更的状态。 + */ static loadSyncStatus(): SyncStatus { try { const data = localStorage.getItem(SYNC_STATUS_KEY); @@ -63,9 +91,49 @@ export class LocalStorageService { } } + /** + * 获取浏览器当前网络连接状态。 + * @returns 浏览器报告的在线状态。 + */ static isOnline(): boolean { return navigator.onLine; } + + private static readonly ALLOW_PERSIST_KEY = 'Hua.Todo_allow_persist'; + + /** + * 设置是否允许将 Todo 待办项和同步状态持久化到浏览器 localStorage。 + * + * 从允许持久化切换为禁止持久化时,会立即清除已落盘的 Todo 待办项与同步状态, + * 避免用户关闭持久化后仍在本机残留业务数据。 + * @param allow true 表示允许落盘;false 表示进入仅内存使用模式。 + */ + static setAllowPersist(allow: boolean): void { + try { + const wasAllowed = this.getAllowPersist(); + localStorage.setItem(this.ALLOW_PERSIST_KEY, String(allow)); + // 只有状态从允许落盘变为禁止落盘时才执行清理,避免重复切换时误删无关配置。 + if (wasAllowed && !allow) { + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(SYNC_STATUS_KEY); + console.log('allowPersist 已禁止:所有持久化任务数据已清空'); + } + } catch (error) { + console.error('Failed to save allowPersist to localStorage:', error); + } + } + + /** + * 读取当前是否允许 localStorage 持久化。 + * @returns 未显式设置或读取失败时默认允许持久化,保持既有离线使用体验。 + */ + static getAllowPersist(): boolean { + try { + return localStorage.getItem(this.ALLOW_PERSIST_KEY) !== 'false'; + } catch { + return true; + } + } } export default LocalStorageService; diff --git a/src/Hua.Todo.Web/src/services/taskNormalizer.ts b/src/Hua.Todo.Web/src/services/taskNormalizer.ts index 19524e4..b6bf36f 100644 --- a/src/Hua.Todo.Web/src/services/taskNormalizer.ts +++ b/src/Hua.Todo.Web/src/services/taskNormalizer.ts @@ -36,20 +36,19 @@ const toIsoString = (value: unknown): string => { export const normalizeTask = (task: any): Task => { const normalized: any = { ...(task ?? {}) }; - if (typeof normalized.id === 'string' && /^\d+$/.test(normalized.id)) { - normalized.id = Number(normalized.id); - } - + // IDs remain as strings (Guids) + normalized.id = typeof normalized.id === 'number' ? String(normalized.id) : (normalized.id ?? ''); + normalized.code = typeof normalized.code === 'string' && normalized.code.trim() ? normalized.code : undefined; normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? ''); normalized.priority = toTaskPriority(normalized.priority); normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted); normalized.createdAt = toIsoString(normalized.createdAt); normalized.updatedAt = toIsoString(normalized.updatedAt); - if (normalized.parentTaskId === null) { + if (normalized.parentTaskId === null || normalized.parentTaskId === 0) { normalized.parentTaskId = undefined; - } else if (typeof normalized.parentTaskId === 'string' && /^\d+$/.test(normalized.parentTaskId)) { - normalized.parentTaskId = Number(normalized.parentTaskId); + } else if (typeof normalized.parentTaskId === 'number') { + normalized.parentTaskId = String(normalized.parentTaskId); } if (Array.isArray(normalized.subTasks)) { diff --git a/src/Hua.Todo.Web/src/types/task.ts b/src/Hua.Todo.Web/src/types/task.ts index 0b5a282..d0e490a 100644 --- a/src/Hua.Todo.Web/src/types/task.ts +++ b/src/Hua.Todo.Web/src/types/task.ts @@ -1,26 +1,73 @@ export type TaskPriority = 0 | 1 | 2; +/** 待办项类型:0=Normal(普通), 1=Meeting(会议) */ +export type TaskType = 0 | 1; + +/** 附件类型:0=本地文件, 1=外部链接 */ +export type AttachmentType = 0 | 1; + +export interface AttachmentItem { + id: string; + taskId: string; + fileName: string; + filePath: string; + fileSize: number; + contentType: string; + attachmentType: AttachmentType; + createdAt: string; +} + export interface Task { - id: number; + id: string; + /** 任务编号(用户级自增字符串,用于展示) */ + code?: string; title: string; priority: TaskPriority; isCompleted: boolean; createdAt: string; updatedAt: string; - parentTaskId?: number; + parentTaskId?: string; subTasks: Task[]; + /** 待办项类型 */ + taskType?: TaskType; + /** 会议纪要/转写文字 */ + meetingNotes?: string; + /** 录音时长(秒) */ + audioDuration?: number; + /** 多行描述文本 */ + description?: string; + /** 附件列表(详情视图使用) */ + attachments?: AttachmentItem[]; + /** 附件数量(列表视图使用) */ + attachmentCount?: number; + // === ABP 审计字段(云同步使用) === + creationTime?: string; + creatorId?: string | null; + lastModificationTime?: string | null; + lastModifierId?: string | null; + isDeleted?: boolean; + deletionTime?: string | null; + deleterId?: string | null; } export interface CreateTaskDto { title: string; priority: TaskPriority; - parentTaskId?: number; + parentTaskId?: string; + /** 待办项类型,默认 0(Normal) */ + taskType?: TaskType; + /** 多行描述文本 */ + description?: string; } export interface UpdateTaskDto { - id: number; + id: string; title?: string; priority?: TaskPriority; + /** 待办项类型 */ + taskType?: TaskType; + /** 多行描述文本 */ + description?: string; } export interface ApiResponse { diff --git a/src/Hua.Todo.Web/vite.config.ts b/src/Hua.Todo.Web/vite.config.ts index 928d821..e7636f4 100644 --- a/src/Hua.Todo.Web/vite.config.ts +++ b/src/Hua.Todo.Web/vite.config.ts @@ -15,10 +15,10 @@ export default defineConfig({ port: 5174, proxy: { '/api': { - target: 'http://localhost:5173', - changeOrigin: true, - secure: false, - }, + target: process.env.VITE_API_TARGET || 'http://localhost:5057', + changeOrigin: true, + secure: false, + }, }, }, }) diff --git a/start-host.ps1 b/start-host.ps1 index c1cc164..a1a4844 100644 --- a/start-host.ps1 +++ b/start-host.ps1 @@ -1,28 +1,54 @@ param( - [switch]$Force = $false + [switch]$Force = $false, + [switch]$Rebuild = $false ) $ErrorActionPreference = "Stop" Write-Host "====================================" -ForegroundColor Cyan -Write-Host " Hua.Todo.Host 启动脚本" -ForegroundColor Cyan +Write-Host " Hua.Todo.Host Start Script" -ForegroundColor Cyan Write-Host "====================================" -ForegroundColor Cyan Write-Host "" $hostProjectPath = Join-Path $PSScriptRoot "src\Hua.Todo.Host" $hostProjectFile = Join-Path $hostProjectPath "Hua.Todo.Host.csproj" +$launchSettingsFile = Join-Path $hostProjectPath "Properties\launchSettings.json" +$outputDir = Join-Path $hostProjectPath "bin\Debug\net10.0" +$hostExe = Join-Path $outputDir "Hua.Todo.Host.exe" if (!(Test-Path $hostProjectFile)) { - Write-Host "❌ Host 项目文件不存在: $hostProjectFile" -ForegroundColor Red + Write-Host "ERROR: Host project file not found: $hostProjectFile" -ForegroundColor Red exit 1 } if (!(Get-Command "dotnet" -ErrorAction SilentlyContinue)) { - Write-Host "❌ 未找到 dotnet,请先安装 .NET SDK" -ForegroundColor Red + Write-Host "ERROR: dotnet not found, please install .NET SDK first" -ForegroundColor Red exit 1 } -Write-Host "[1/2] 检查 Hua.Todo.Host 是否已在运行..." -ForegroundColor Yellow +$envSettings = @{} +$appUrl = "http://localhost:5173" +if (Test-Path $launchSettingsFile) { + try { + $launchSettings = Get-Content $launchSettingsFile -Raw | ConvertFrom-Json + $profile = $launchSettings.profiles.http + if ($profile) { + $appUrl = $profile.applicationUrl + if ($profile.environmentVariables) { + foreach ($key in $profile.environmentVariables.PSObject.Properties.Name) { + $envSettings[$key] = $profile.environmentVariables.$key + } + } + } + Write-Host "OK: Loaded launch settings from $launchSettingsFile" -ForegroundColor Green + } catch { + Write-Host "WARNING: Failed to read launch settings, using defaults: $_" -ForegroundColor Yellow + } +} else { + Write-Host "WARNING: launchSettings.json not found, using defaults" -ForegroundColor Yellow +} + +Write-Host "[1/2] Checking if Hua.Todo.Host is running..." -ForegroundColor Yellow $runningProcesses = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe'" | Where-Object { $_.CommandLine -and ( @@ -33,58 +59,80 @@ $runningProcesses = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe'" | if ($runningProcesses) { $pids = ($runningProcesses.ProcessId | Sort-Object) -join ", " - Write-Host "⚠️ Hua.Todo.Host 可能已在运行中" -ForegroundColor Yellow - Write-Host " 进程 ID: ($pids)" -ForegroundColor Gray + Write-Host "WARNING: Hua.Todo.Host may be running" -ForegroundColor Yellow + Write-Host " Process IDs: ($pids)" -ForegroundColor Gray if ($Force) { Write-Host "" - Write-Host "🔄 强制关闭并重新启动..." -ForegroundColor Yellow + Write-Host "Restarting..." -ForegroundColor Yellow try { $runningProcesses | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } - Write-Host "✅ 旧进程已停止" -ForegroundColor Green + Write-Host "Old process stopped" -ForegroundColor Green Start-Sleep -Seconds 1 } catch { - Write-Host "❌ 停止进程失败: $_" -ForegroundColor Red + Write-Host "ERROR: Failed to stop process: $_" -ForegroundColor Red exit 1 } } else { - Write-Host " 如果需要重新启动,请使用 -Force 参数" -ForegroundColor Gray + Write-Host " Use -Force to restart" -ForegroundColor Gray Write-Host "" - Write-Host "💡 API 地址:" -ForegroundColor Yellow - Write-Host " http://localhost:5173" -ForegroundColor Cyan + Write-Host "API Address:" -ForegroundColor Yellow + Write-Host " $appUrl" -ForegroundColor Cyan Write-Host "" exit 0 } } else { - Write-Host "✓ Hua.Todo.Host 未运行" -ForegroundColor Green + Write-Host "OK: Hua.Todo.Host is not running" -ForegroundColor Green Write-Host "" } -Write-Host "[2/2] 启动 Hua.Todo.Host..." -ForegroundColor Yellow -Write-Host "📂 工作目录: $hostProjectPath" -ForegroundColor Gray -Write-Host "🚀 启动服务..." -ForegroundColor Green +Write-Host "[2/2] Starting Hua.Todo.Host..." -ForegroundColor Yellow +Write-Host "Working directory: $hostProjectPath" -ForegroundColor Gray + +$needBuild = $Rebuild -or !(Test-Path $hostExe) + +if ($needBuild) { + Write-Host "Building..." -ForegroundColor Yellow + try { + $buildArgs = @("build", $hostProjectFile, "--configuration", "Debug") + if ($Rebuild) { + $buildArgs += "--no-incremental" + } + $buildProcess = Start-Process -FilePath "dotnet" -ArgumentList $buildArgs -WorkingDirectory $hostProjectPath -Wait -PassThru -NoNewWindow + if ($buildProcess.ExitCode -ne 0) { + Write-Host "ERROR: Build failed, exit code: $($buildProcess.ExitCode)" -ForegroundColor Red + exit $buildProcess.ExitCode + } + Write-Host "Build succeeded" -ForegroundColor Green + } catch { + Write-Host "ERROR: Build failed: $_" -ForegroundColor Red + exit 1 + } +} else { + Write-Host "OK: Found compiled executable, skipping build" -ForegroundColor Green +} + +Write-Host "Starting service..." -ForegroundColor Green Write-Host "" try { - $argumentList = @( - "run", - "--project", $hostProjectFile, - "--launch-profile", "http" - ) + foreach ($key in $envSettings.Keys) { + [Environment]::SetEnvironmentVariable($key, $envSettings[$key]) + } + [Environment]::SetEnvironmentVariable("ASPNETCORE_URLS", $appUrl) + $hostProcess = Start-Process -FilePath $hostExe -WorkingDirectory $outputDir -PassThru - $hostProcess = Start-Process -FilePath "dotnet" -ArgumentList $argumentList -WorkingDirectory $hostProjectPath -PassThru - - Write-Host "✅ Hua.Todo.Host 已启动" -ForegroundColor Green - Write-Host " 进程 ID: ($($hostProcess.Id))" -ForegroundColor Gray + Write-Host "OK: Hua.Todo.Host started" -ForegroundColor Green + Write-Host " Process ID: ($($hostProcess.Id))" -ForegroundColor Gray Write-Host "" - Write-Host "💡 API 地址:" -ForegroundColor Yellow - Write-Host " http://localhost:5173" -ForegroundColor Cyan + Write-Host "API Address:" -ForegroundColor Yellow + Write-Host " $appUrl" -ForegroundColor Cyan Write-Host "" - Write-Host "📝 提示:" -ForegroundColor Yellow - Write-Host " - 使用 -Force 参数可以强制重启" -ForegroundColor Gray + Write-Host "Tips:" -ForegroundColor Yellow + Write-Host " - Use -Force to restart" -ForegroundColor Gray + Write-Host " - Use -Rebuild to force full rebuild" -ForegroundColor Gray Write-Host "" } catch { - Write-Host "❌ 启动失败: $_" -ForegroundColor Red + Write-Host "ERROR: Failed to start: $_" -ForegroundColor Red exit 1 -} - +} \ No newline at end of file diff --git a/test/Hua.Todo.Avalonia.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs b/test/Hua.Todo.Avalonia.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs new file mode 100644 index 0000000..5ff04af --- /dev/null +++ b/test/Hua.Todo.Avalonia.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs @@ -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.Avalonia.Tests.CloudSync; + +/// +/// CloudSyncProxyMiddleware 行为集成测试(Avalonia 端)。 +/// 验证云同步路径代理转发、无 URL 时返回 503。 +/// +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(); + } +} diff --git a/test/Hua.Todo.Avalonia.Tests/Hua.Todo.Avalonia.Tests.csproj b/test/Hua.Todo.Avalonia.Tests/Hua.Todo.Avalonia.Tests.csproj new file mode 100644 index 0000000..87e3dd8 --- /dev/null +++ b/test/Hua.Todo.Avalonia.Tests/Hua.Todo.Avalonia.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/Hua.Todo.Host.Tests/Attachments/AttachmentModelTests.cs b/test/Hua.Todo.Host.Tests/Attachments/AttachmentModelTests.cs new file mode 100644 index 0000000..133fefc --- /dev/null +++ b/test/Hua.Todo.Host.Tests/Attachments/AttachmentModelTests.cs @@ -0,0 +1,249 @@ +using Hua.Todo.Application.Models; +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Host.Tests.Attachments; + +/// +/// 工单 04 附件与描述数据模型测试。 +/// +public class AttachmentModelTests +{ + [Fact] + public void AttachmentEntity_DefaultValues_AreCorrect() + { + var entity = new AttachmentEntity(); + + Assert.Equal(Guid.Empty, entity.Id); + Assert.Equal(Guid.Empty, entity.TaskId); + Assert.Equal(string.Empty, entity.FileName); + Assert.Equal(string.Empty, entity.FilePath); + Assert.Equal(0L, entity.FileSize); + Assert.Equal(string.Empty, entity.ContentType); + Assert.Equal(AttachmentType.LocalFile, entity.AttachmentType); + Assert.True(entity.CreatedAt <= DateTime.UtcNow); + } + + [Fact] + public void AttachmentType_LocalFile_IsZero() + { + Assert.Equal(0, (int)AttachmentType.LocalFile); + } + + [Fact] + public void AttachmentType_ExternalLink_IsOne() + { + Assert.Equal(1, (int)AttachmentType.ExternalLink); + } + + [Fact] + public void AttachmentEntity_CanBeCreatedWithValues() + { + var id = Guid.NewGuid(); + var taskId = Guid.NewGuid(); + var entity = new AttachmentEntity + { + Id = id, + TaskId = taskId, + FileName = "需求文档.pdf", + FilePath = "/attachments/42_需求文档.pdf", + FileSize = 204800, + ContentType = "application/pdf", + AttachmentType = AttachmentType.LocalFile, + CreatedAt = new DateTime(2026, 6, 16, 10, 0, 0, DateTimeKind.Utc) + }; + + Assert.Equal(id, entity.Id); + Assert.Equal(taskId, entity.TaskId); + Assert.Equal("需求文档.pdf", entity.FileName); + Assert.Equal("/attachments/42_需求文档.pdf", entity.FilePath); + Assert.Equal(204800L, entity.FileSize); + Assert.Equal("application/pdf", entity.ContentType); + Assert.Equal(AttachmentType.LocalFile, entity.AttachmentType); + Assert.Equal(new DateTime(2026, 6, 16, 10, 0, 0, DateTimeKind.Utc), entity.CreatedAt); + } + + [Fact] + public void AttachmentEntity_ExternalLinkType_HasDefaultFileSizeZero() + { + var entity = new AttachmentEntity + { + AttachmentType = AttachmentType.ExternalLink, + FilePath = "https://example.com/doc" + }; + + Assert.Equal(0L, entity.FileSize); + Assert.Equal(string.Empty, entity.ContentType); + } + + [Fact] + public void AttachmentDto_Properties_AreMapped() + { + var id = Guid.NewGuid(); + var taskId = Guid.NewGuid(); + var dto = new AttachmentDto + { + Id = id, + TaskId = taskId, + FileName = "test.txt", + FilePath = "/path/to/test.txt", + FileSize = 1024, + ContentType = "text/plain", + AttachmentType = AttachmentType.LocalFile, + CreatedAt = DateTime.UtcNow + }; + + Assert.Equal(id, dto.Id); + Assert.Equal(taskId, dto.TaskId); + Assert.Equal("test.txt", dto.FileName); + Assert.Equal(1024L, dto.FileSize); + Assert.Equal("text/plain", dto.ContentType); + Assert.Equal(AttachmentType.LocalFile, dto.AttachmentType); + } + + [Fact] + public void UploadAttachmentRequest_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var request = new UploadAttachmentRequest + { + TaskId = taskId, + FileName = "report.pdf", + Base64Content = "dGVzdA==", + ContentType = "application/pdf" + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Equal("report.pdf", request.FileName); + Assert.Equal("dGVzdA==", request.Base64Content); + Assert.Equal("application/pdf", request.ContentType); + } + + [Fact] + public void AddLinkRequest_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var request = new AddLinkRequest + { + TaskId = taskId, + Url = "https://example.com/doc", + FileName = "参考文档" + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Equal("https://example.com/doc", request.Url); + Assert.Equal("参考文档", request.FileName); + } + + [Fact] + public void AddLinkRequest_FileName_CanBeNull() + { + var taskId = Guid.NewGuid(); + var request = new AddLinkRequest + { + TaskId = taskId, + Url = "https://example.com/doc" + }; + + Assert.Null(request.FileName); + } + + [Fact] + public void TaskEntity_DefaultDescription_IsNull() + { + var entity = new TaskEntity(); + Assert.Null(entity.Description); + } + + [Fact] + public void TaskEntity_Description_CanBeSet() + { + var entity = new TaskEntity + { + Description = "这是一个多行描述,用于记录详细说明。" + }; + + Assert.Equal("这是一个多行描述,用于记录详细说明。", entity.Description); + } + + [Fact] + public void TaskEntity_DefaultAttachments_IsEmpty() + { + var entity = new TaskEntity(); + Assert.NotNull(entity.Attachments); + Assert.Empty(entity.Attachments); + } + + [Fact] + public void TaskDto_IncludesDescription() + { + var dto = new TaskDto + { + Description = "测试描述" + }; + + Assert.Equal("测试描述", dto.Description); + } + + [Fact] + public void TaskDto_IncludesAttachmentCount() + { + var dto = new TaskDto + { + AttachmentCount = 3 + }; + + Assert.Equal(3, dto.AttachmentCount); + } + + [Fact] + public void CreateTaskDto_IncludesDescription() + { + var dto = new CreateTaskDto + { + Title = "测试任务", + Description = "描述内容" + }; + + Assert.Equal("描述内容", dto.Description); + } + + [Fact] + public void UpdateTaskDto_Description_IsOptional() + { + var dto = new UpdateTaskDto + { + Id = Guid.NewGuid(), + Title = "更新标题" + // 不传 Description —— 保持原值 + }; + + Assert.Null(dto.Description); + } + + [Fact] + public void OpenAttachmentResponse_OpenedProperty() + { + var response = new OpenAttachmentResponse { Opened = true }; + Assert.True(response.Opened); + + var failed = new OpenAttachmentResponse { Opened = false }; + Assert.False(failed.Opened); + } + + [Fact] + public void Description_Property_IsDefinedOnTaskEntity() + { + var property = typeof(TaskEntity).GetProperty("Description"); + Assert.NotNull(property); + Assert.Equal(typeof(string), property.PropertyType); + } + + [Fact] + public void Attachments_Property_IsDefinedOnTaskEntity() + { + var property = typeof(TaskEntity).GetProperty("Attachments"); + Assert.NotNull(property); + Assert.Equal(typeof(List), property.PropertyType); + } +} diff --git a/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncAuthSecurityPolicyTests.cs b/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncAuthSecurityPolicyTests.cs new file mode 100644 index 0000000..6e88502 --- /dev/null +++ b/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncAuthSecurityPolicyTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Hua.Todo.Application.Common; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.Core.Entities; +using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; +using Hua.Todo.HttpApi.AspNetCore.DynamicApi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Hua.Todo.Host.Tests.CloudSync; + +/// +/// CloudSync 登录后访问安全策略的端到端测试。 +/// 覆盖 DynamicApi 响应包装、SessionAuthenticationHandler 认证和 SecurityPolicyService 当前用户解析链路。 +/// +public sealed class CloudSyncAuthSecurityPolicyTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly WebApplication _app; + private readonly HttpClient _client; + + /// + /// 创建使用 SQLite 内存库的最小 Host 管道。 + /// + public CloudSyncAuthSecurityPolicyTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddApplicationServices("Data Source=:memory:"); + builder.Services.AddDbContext(options => options.UseSqlite(_connection)); + builder.Services.AddCloudSyncServer(); + + _app = builder.Build(); + _app.UseAuthentication(); + _app.UseAuthorization(); + _app.UseDynamicApi(); + + using (var scope = _app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + SeedAdmin(db, scope.ServiceProvider.GetRequiredService>()); + } + + _app.StartAsync().GetAwaiter().GetResult(); + _client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) }; + } + + /// + /// 验证登录成功后,携带返回的 Bearer Token 能获取当前用户安全策略。 + /// + [Fact] + public async Task GetSecurityPolicy_AfterLogin_ReturnsPolicy() + { + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new LoginRequest + { + UserName = "admin", + Password = "123456" + }); + + Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); + var loginPayload = await ReadDynamicApiResponseAsync(loginResponse); + Assert.True(loginPayload.Success); + Assert.False(string.IsNullOrWhiteSpace(loginPayload.Data?.AccessToken)); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginPayload.Data.AccessToken); + var policyResponse = await _client.GetAsync("/api/security/policy"); + + Assert.Equal(HttpStatusCode.OK, policyResponse.StatusCode); + var policyPayload = await ReadDynamicApiResponseAsync(policyResponse); + Assert.True(policyPayload.Success); + Assert.True(policyPayload.Data?.AllowPersist); + Assert.True(policyPayload.Data?.AllowSync); + } + + /// + /// 释放测试 Host 与 SQLite 连接。 + /// + public void Dispose() + { + _client.Dispose(); + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + _connection.Dispose(); + } + + private static void SeedAdmin(TodoDbContext db, IPasswordHasher hasher) + { + var user = new UserEntity + { + Id = Guid.NewGuid(), + UserName = "admin", + Role = "admin", + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }; + user.PasswordHash = hasher.HashPassword(user, "123456"); + + db.Users.Add(user); + db.SecurityPolicies.Add(new SecurityPolicyEntity + { + Id = Guid.NewGuid(), + UserId = user.Id, + AllowPersist = true, + AllowSync = true, + IsTrustedDeviceOnly = false + }); + db.SaveChanges(); + } + + private static async Task> ReadDynamicApiResponseAsync(HttpResponseMessage response) + { + var payload = await response.Content.ReadFromJsonAsync>(new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return payload ?? throw new InvalidOperationException("DynamicApi 响应为空。"); + } + + private sealed class DynamicApiPayload + { + public bool Success { get; set; } + public T? Data { get; set; } + } +} diff --git a/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncEndpointRegistrationTests.cs b/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncEndpointRegistrationTests.cs new file mode 100644 index 0000000..df460ca --- /dev/null +++ b/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncEndpointRegistrationTests.cs @@ -0,0 +1,82 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Hua.Todo.Application.Common; +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Services; +using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; +using Xunit; + +namespace Hua.Todo.Host.Tests.CloudSync; + +/// +/// 云同步核心服务 DI 注册集成测试。 +/// 模拟 Host 的完整 DI 链路:AddApplicationServices + AddCloudSyncServer, +/// 验证所有关键服务可被正确解析。 +/// +public class CloudSyncEndpointRegistrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + + public CloudSyncEndpointRegistrationTests() + { + // 使用共享缓存的 SQLite 内存数据库 + _connection = new SqliteConnection("Data Source=CloudSyncRegTests;Mode=Memory;Cache=Shared"); + _connection.Open(); + + var services = new ServiceCollection(); + services.AddLogging(); + // 模拟 Host 的完整 DI 链路 + services.AddApplicationServices("Data Source=CloudSyncRegTests;Mode=Memory;Cache=Shared"); + services.AddCloudSyncServer(); + + _provider = services.BuildServiceProvider(); + + // 确保数据库表已创建 + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } + + [Fact] + public void ResolveCloudProbeService_ShouldSucceed() + { + var service = _provider.GetRequiredService(); + Assert.NotNull(service); + } + + [Fact] + public void ResolveCloudAuthService_ShouldSucceed() + { + var service = _provider.GetRequiredService(); + Assert.NotNull(service); + } + + [Fact] + public void ResolveCloudTaskSyncService_ShouldSucceed() + { + var service = _provider.GetRequiredService(); + Assert.NotNull(service); + } + + [Fact] + public void ResolveSecurityPolicyService_ShouldSucceed() + { + var service = _provider.GetRequiredService(); + Assert.NotNull(service); + } + + [Fact] + public void ResolveCloudAdminService_ShouldSucceed() + { + var service = _provider.GetRequiredService(); + Assert.NotNull(service); + } + + public void Dispose() + { + _provider.Dispose(); + _connection.Dispose(); + } +} diff --git a/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs b/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs new file mode 100644 index 0000000..301e097 --- /dev/null +++ b/test/Hua.Todo.Host.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs @@ -0,0 +1,95 @@ +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.Host.Tests.CloudSync; + +/// +/// CloudSyncProxyMiddleware 行为集成测试。 +/// 验证云同步路径代理转发、无 URL 时返回 503。 +/// settings 端点已迁移至 DynamicApi(/api/cloudSyncProxySettings),不在本测试覆盖范围。 +/// +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(); + } +} diff --git a/src/Hua.Todo.Tests/CloudSyncDtoTests.cs b/test/Hua.Todo.Host.Tests/CloudSyncDtoTests.cs similarity index 97% rename from src/Hua.Todo.Tests/CloudSyncDtoTests.cs rename to test/Hua.Todo.Host.Tests/CloudSyncDtoTests.cs index 3bf83f9..c011978 100644 --- a/src/Hua.Todo.Tests/CloudSyncDtoTests.cs +++ b/test/Hua.Todo.Host.Tests/CloudSyncDtoTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; -using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.Services.CloudSync.Models; using Hua.Todo.Core.Entities; using Xunit; -namespace Hua.Todo.Tests; +namespace Hua.Todo.Host.Tests; /// /// CloudSync DTO 单元测试。 @@ -134,4 +134,4 @@ public class CloudSyncDtoTests // Assert Assert.Contains(guid.ToString(), json); } -} \ No newline at end of file +} diff --git a/src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs b/test/Hua.Todo.Host.Tests/CloudTaskSyncServiceSqliteTests.cs similarity index 95% rename from src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs rename to test/Hua.Todo.Host.Tests/CloudTaskSyncServiceSqliteTests.cs index 3cbfca0..b26141f 100644 --- a/src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs +++ b/test/Hua.Todo.Host.Tests/CloudTaskSyncServiceSqliteTests.cs @@ -1,12 +1,13 @@ +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.Application.Services.CloudSync.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.CloudSync.Services; -using Hua.Todo.Application.Data; +using Microsoft.Extensions.Logging.Abstractions; using Hua.Todo.Core.Entities; using Xunit; -namespace Hua.Todo.Tests; +namespace Hua.Todo.Host.Tests; /// /// CloudTaskSyncService SQLite 集成测试(验证 UNIQUE 约束不会在生产环境中触发)。 @@ -44,7 +45,7 @@ public class CloudTaskSyncServiceSqliteTests : IDisposable }); _dbContext.SaveChanges(); - _service = new CloudTaskSyncService(_dbContext); + _service = new CloudTaskSyncService(_dbContext, NullLogger.Instance); } public void Dispose() @@ -242,7 +243,7 @@ public class CloudTaskSyncServiceSqliteTests : IDisposable .UseSqlite(_connection) .Options; using var dbContextB = new TodoDbContext(options); - var serviceB = new CloudTaskSyncService(dbContextB); + var serviceB = new CloudTaskSyncService(dbContextB, NullLogger.Instance); var resyncResponse = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None); Assert.Equal(2, resyncResponse.Tasks.Count); @@ -295,7 +296,7 @@ public class CloudTaskSyncServiceSqliteTests : IDisposable .UseSqlite(_connection) .Options; using var dbContextB = new TodoDbContext(options); - var serviceB = new CloudTaskSyncService(dbContextB); + var serviceB = new CloudTaskSyncService(dbContextB, NullLogger.Instance); // 重试同步 - 不应抛出 UNIQUE 约束 var response2 = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None); @@ -393,7 +394,7 @@ public class CloudTaskSyncServiceSqliteTests : IDisposable .UseSqlite(_connection) .Options; using var dbContextB = new TodoDbContext(options); - var serviceB = new CloudTaskSyncService(dbContextB); + var serviceB = new CloudTaskSyncService(dbContextB, NullLogger.Instance); var request = new SyncRequest { diff --git a/src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs b/test/Hua.Todo.Host.Tests/CloudTaskSyncServiceTests.cs similarity index 98% rename from src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs rename to test/Hua.Todo.Host.Tests/CloudTaskSyncServiceTests.cs index 6823b91..0de6a74 100644 --- a/src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs +++ b/test/Hua.Todo.Host.Tests/CloudTaskSyncServiceTests.cs @@ -1,12 +1,13 @@ +using Hua.Todo.Application.Repositories; +using Hua.Todo.Application.Services.CloudSync.Models; +using Hua.Todo.Application.Services.CloudSync.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using Hua.Todo.Application.CloudSync.Models; -using Hua.Todo.Application.CloudSync.Services; -using Hua.Todo.Application.Data; +using Microsoft.Extensions.Logging.Abstractions; using Hua.Todo.Core.Entities; using Xunit; -namespace Hua.Todo.Tests; +namespace Hua.Todo.Host.Tests; /// /// CloudTaskSyncService 单元测试。 @@ -25,7 +26,7 @@ public class CloudTaskSyncServiceTests : IDisposable .Options; _dbContext = new TodoDbContext(options); - _service = new CloudTaskSyncService(_dbContext); + _service = new CloudTaskSyncService(_dbContext, NullLogger.Instance); } public void Dispose() @@ -675,4 +676,4 @@ public class CloudTaskSyncServiceTests : IDisposable Assert.Single(response.Tasks); Assert.Equal("Valid Task", response.Tasks.First().Title); } -} \ No newline at end of file +} diff --git a/test/Hua.Todo.Host.Tests/DatabaseMigrationTests.cs b/test/Hua.Todo.Host.Tests/DatabaseMigrationTests.cs new file mode 100644 index 0000000..1ec4899 --- /dev/null +++ b/test/Hua.Todo.Host.Tests/DatabaseMigrationTests.cs @@ -0,0 +1,290 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Xunit; +using System.Data.Common; +using Hua.Todo.Application.Repositories; + +namespace Hua.Todo.Host.Tests; + +/// +/// 数据库迁移集成测试,验证迁移链的完整性与幂等性。 +/// 覆盖 Maui/Avalonia 中 InitializeDatabase 的 Migrate → EnsureCreated 回退链路。 +/// +public class DatabaseMigrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public DatabaseMigrationTests() + { + // 使用独立的内存数据库(每次测试独立的连接) + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + } + + public void Dispose() + { + _connection.Dispose(); + } + + /// + /// 创建新的 DbContext 实例。 + /// + private TodoDbContext CreateContext() + { + return new TodoDbContext(_options); + } + + /// + /// 验证所有迁移可以从头开始成功应用(Migrate 从零到当前模型)。 + /// + [Fact] + public void Migrate_FromScratch_AllMigrationsApplied() + { + // Arrange + using var context = CreateContext(); + + // Act: 执行全部迁移 + context.Database.Migrate(); + + // Assert: 验证所有核心表已创建 + 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); + } + + /// + /// 验证 Migrate 是幂等的:第二次调用 Migrate 不会报错。 + /// + [Fact] + public void Migrate_Idempotent_SecondCallDoesNotThrow() + { + // Arrange + using var context = CreateContext(); + context.Database.Migrate(); + + // Act & Assert: 第二次 Migrate 不应抛出异常 + var exception = Record.Exception(() => context.Database.Migrate()); + Assert.Null(exception); + } + + /// + /// 验证 T_Tasks 表包含所有期望的列(ABP 审计字段 + 业务字段 + v1.3.0 新增字段)。 + /// + [Fact] + public void T_Tasks_HasAllExpectedColumns() + { + // Arrange + using var context = CreateContext(); + context.Database.Migrate(); + + // Act + var columns = GetColumnNames(context, "T_Tasks"); + + // Assert: ABP 审计字段 + Assert.Contains("Id", columns); + Assert.Contains("ExtraProperties", columns); + Assert.Contains("ConcurrencyStamp", columns); + Assert.Contains("CreationTime", columns); + Assert.Contains("CreatorId", columns); + Assert.Contains("LastModificationTime", columns); + Assert.Contains("LastModifierId", columns); + Assert.Contains("IsDeleted", columns); + Assert.Contains("DeletionTime", columns); + Assert.Contains("DeleterId", columns); + + // Assert: 业务字段 + Assert.Contains("UserId", columns); + Assert.Contains("Title", columns); + Assert.Contains("Priority", columns); + Assert.Contains("IsCompleted", columns); + Assert.Contains("Code", columns); + Assert.Contains("ParentTaskId", columns); + + // Assert: v1.3.0 新增字段 + Assert.Contains("TaskType", columns); + Assert.Contains("MeetingNotes", columns); + Assert.Contains("AudioDuration", columns); + Assert.Contains("Description", columns); + } + + /// + /// 验证 Attachments 表存在且包含期望的列。 + /// + [Fact] + public void Attachments_HasAllExpectedColumns() + { + // Arrange + using var context = CreateContext(); + context.Database.Migrate(); + + // Act + var columns = GetColumnNames(context, "Attachments"); + + // Assert + Assert.Contains("Id", columns); + Assert.Contains("FileName", columns); + Assert.Contains("FilePath", columns); + Assert.Contains("ContentType", columns); + Assert.Contains("FileSize", columns); + Assert.Contains("AttachmentType", columns); + Assert.Contains("CreatedAt", columns); + Assert.Contains("TaskId", columns); + } + + /// + /// 验证 EnsureCreated 可以独立创建完整 schema(不依赖迁移历史)。 + /// 模拟 MauiProgram.InitializeDatabase 的 fallback 路径。 + /// + [Fact] + public void EnsureCreated_FromScratch_CreatesAllTables() + { + // Arrange + using var context = CreateContext(); + + // Act: 使用 EnsureCreated 而非 Migrate + context.Database.EnsureCreated(); + + // Assert: 所有核心表存在 + 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); + } + + /// + /// 验证 Migrate 后所有核心表(包括 Attachments)都存在。 + /// + [Fact] + public void Migrate_AllTablesExist_IncludingAttachments() + { + // Arrange + using var context = CreateContext(); + + // Act: 执行全部迁移 + context.Database.Migrate(); + + // Assert: 所有核心表已创建 + 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); + } + + /// + /// 验证迁移后可以正常读写 T_Tasks 表。 + /// + [Fact] + public void Migrate_CanInsertAndQueryTask() + { + // Arrange + using var context = CreateContext(); + context.Database.Migrate(); + + // 创建测试用户 + var userId = Guid.NewGuid(); + context.Users.Add(new Core.Entities.UserEntity + { + Id = userId, + UserName = "test", + PasswordHash = "hash", + PasswordSalt = "salt", + Role = "User" + }); + context.SaveChanges(); + + // Act: 插入任务 + var taskId = Guid.NewGuid(); + context.Tasks.Add(new Core.Entities.TaskEntity + { + Id = taskId, + UserId = userId, + Title = "测试任务", + Priority = Core.Entities.TaskPriority.High, + Code = "T001", + Description = "测试描述", + TaskType = Core.Entities.TaskType.Normal, + CreationTime = DateTime.UtcNow, + CreatorId = userId + }); + context.SaveChanges(); + + // Assert: 查询验证 + var task = context.Tasks.Find(taskId); + Assert.NotNull(task); + Assert.Equal("测试任务", task!.Title); + Assert.Equal("测试描述", task.Description); + Assert.Equal(Core.Entities.TaskType.Normal, task.TaskType); + } + + /// + /// 验证迁移历史表包含所有预期迁移。 + /// + [Fact] + public void Migrate_RecordsAllMigrationsInHistory() + { + // Arrange + using var context = CreateContext(); + + // Act + context.Database.Migrate(); + + // Assert + var history = context.GetService(); + var appliedMigrations = history.GetAppliedMigrations().Select(m => m.MigrationId).ToList(); + Assert.NotEmpty(appliedMigrations); + // 迁移已合并为单一 InitialCreate,仅验证该迁移已记录 + Assert.Contains(appliedMigrations, m => m.Contains("InitialCreate")); + } + + // ========== 辅助方法 ========== + + /// + /// 获取数据库中所有表名。 + /// + private static List GetTableNames(TodoDbContext context) + { + return context.Database.SqlQueryRaw( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__EF%' ORDER BY name" + ).ToList(); + } + + /// + /// 获取指定表的列名列表。 + /// 使用原始 ADO.NET 查询 PRAGMA table_info 以避免 EF Core 列映射问题。 + /// + private static List GetColumnNames(TodoDbContext context, string tableName) + { + var columns = new List(); + 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()) + { + // PRAGMA table_info 返回: cid(0), name(1), type(2), notnull(3), dflt_value(4), pk(5) + columns.Add(reader.GetString(1)); + } + return columns; + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Tests/DynamicMcpToolExtensionsTests.cs b/test/Hua.Todo.Host.Tests/DynamicMcpToolExtensionsTests.cs similarity index 57% rename from src/Hua.Todo.Tests/DynamicMcpToolExtensionsTests.cs rename to test/Hua.Todo.Host.Tests/DynamicMcpToolExtensionsTests.cs index b16710a..0712902 100644 --- a/src/Hua.Todo.Tests/DynamicMcpToolExtensionsTests.cs +++ b/test/Hua.Todo.Host.Tests/DynamicMcpToolExtensionsTests.cs @@ -1,13 +1,16 @@ using System.ComponentModel; using System.Reflection; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using Hua.Todo.Application.Interfaces; -using Hua.Todo.Application.Mcp; +using Hua.Todo.HttpApi.AspNetCore.Mcp; +using Hua.Todo.HttpApi.Interfaces; using Hua.Todo.Application.Models; +using Hua.Todo.Application.Services.Interfaces; using Xunit; -namespace Hua.Todo.Tests; +namespace Hua.Todo.Host.Tests; /// /// DynamicMcpToolExtensions 单元测试。 @@ -173,30 +176,26 @@ public class DynamicMcpToolExtensionsTests #region 动态工具注册(集成测试) /// - /// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现 ITaskService 并生成工具。 - /// 工具数量 = ITaskService 的公共方法数(9 个)。 + /// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现所有实现接口并生成工具。 + /// ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 23。 + /// CloudSync 服务接口已通过 [RemoteService(IsEnabled = false)] 排除。 /// [Fact] public void WithDynamicApiTools_ScansAssemblyAndRegistersTools() { - // Arrange var services = new ServiceCollection(); services.AddScoped(); - - // Act - 仅注册工具到 DI,不绑定传输层 - services.AddMcpServer() - .WithDynamicApiTools(); + services.AddLogging(); + services.AddMcpServer().WithDynamicApiTools(); var provider = services.BuildServiceProvider(); - - // Assert:验证 McpServerTool 实例已注册到 DI var tools = provider.GetServices().ToList(); Assert.NotEmpty(tools); - // ITaskService 有 9 个公共方法 - Assert.Equal(9, tools.Count); + // ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 23 + Assert.Equal(23, tools.Count); - // 工具名应与推导规则一致 + // ITaskService 工具名应与推导规则一致 var toolNames = tools.Select(t => t.ProtocolTool.Name).OrderBy(n => n).ToList(); Assert.Contains("task_get_all_tasks", toolNames); Assert.Contains("task_get_task_by_id", toolNames); @@ -207,6 +206,12 @@ public class DynamicMcpToolExtensionsTests Assert.Contains("task_toggle_complete", toolNames); Assert.Contains("task_delete_task", toolNames); Assert.Contains("task_get_sub_tasks", toolNames); + + // IVoiceService 工具名也应存在 + Assert.Contains("voice_execute_command", toolNames); + Assert.Contains("voice_confirm_command", toolNames); + Assert.Contains("voice_get_breakdown", toolNames); + Assert.Contains("voice_confirm_breakdown", toolNames); } /// @@ -234,6 +239,169 @@ public class DynamicMcpToolExtensionsTests Assert.True(deleteTool.ProtocolTool.Annotations?.DestructiveHint == true); } + /// + /// 验证所有生成的 MCP 工具都携带有意义的中文描述。 + /// 描述不得为空,且至少包含一个中文字符。 + /// + [Fact] + public void WithDynamicApiTools_AllToolsHaveMeaningfulDescriptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddScoped(); + + // Act + services.AddMcpServer() + .WithDynamicApiTools(); + + var provider = services.BuildServiceProvider(); + var tools = provider.GetServices().ToList(); + + // Assert: 每个工具的描述非空且包含中文 + foreach (var tool in tools) + { + var desc = tool.ProtocolTool.Description; + Assert.False(string.IsNullOrWhiteSpace(desc), + $"工具 {tool.ProtocolTool.Name} 的描述不应为空"); + + // 验证包含中文字符(Unicode 范围 CJK Unified Ideographs) + var hasChinese = desc.Any(c => c >= 0x4E00 && c <= 0x9FFF); + Assert.True(hasChinese, + $"工具 {tool.ProtocolTool.Name} 的描述应包含中文,当前: {desc}"); + } + } + + /// + /// 验证有参数的工具(CreateTask、UpdateTask、ToggleComplete、DeleteTask、GetTaskById、GetSubTasks) + /// 生成了 InputSchema;无参数的工具(GetAllTasks、GetActiveTasks、GetCompletedTasks) + /// 的 InputSchema 为默认空结构。 + /// + [Fact] + public void WithDynamicApiTools_ToolsHaveInputSchemas() + { + // Arrange + var services = new ServiceCollection(); + services.AddScoped(); + + // Act + services.AddMcpServer() + .WithDynamicApiTools(); + + var provider = services.BuildServiceProvider(); + var tools = provider.GetServices().ToList(); + + // 有参数的方法应生成非空 InputSchema + var toolsWithParams = new[] { "task_create_task", "task_update_task", "task_toggle_complete", + "task_delete_task", "task_get_task_by_id", "task_get_sub_tasks" }; + foreach (var name in toolsWithParams) + { + var tool = tools.First(t => t.ProtocolTool.Name == name); + var schema = tool.ProtocolTool.InputSchema; + Assert.True(schema.ValueKind != JsonValueKind.Undefined, + $"工具 {name} 应有 InputSchema"); + } + + // 无参数的方法应有空 InputSchema(SDK 默认行为) + var toolsWithoutParams = new[] { "task_get_all_tasks", "task_get_active_tasks", "task_get_completed_tasks" }; + foreach (var name in toolsWithoutParams) + { + var tool = tools.First(t => t.ProtocolTool.Name == name); + // 无参数方法:InputSchema 可能为 Undefined 或空对象 + Assert.True(tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Undefined + || (tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Object + && tool.ProtocolTool.InputSchema.GetProperty("type").GetString() == "object"), + $"无参数工具 {name} 的 InputSchema 应为 Undefined 或空 object 类型"); + } + } + + /// + /// 通过 DI 直接解析 MockTaskService 并调用其方法, + /// 验证服务实现可正常工作(返回预期类型与值)。 + /// + [Fact] + public async Task MockTaskService_Invocation_ReturnsExpectedResults() + { + // Arrange + var services = new ServiceCollection(); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + // Act & Assert: 无参查询方法 + var allTasks = await service.GetAllTasksAsync(); + Assert.NotNull(allTasks); + Assert.Empty(allTasks); + + var activeTasks = await service.GetActiveTasksAsync(); + Assert.NotNull(activeTasks); + Assert.Empty(activeTasks); + + var completedTasks = await service.GetCompletedTasksAsync(); + Assert.NotNull(completedTasks); + Assert.Empty(completedTasks); + + // Act & Assert: 单参查询方法 + var testId = Guid.NewGuid(); + var taskById = await service.GetTaskByIdAsync(testId); + Assert.Null(taskById); + + var subTasks = await service.GetSubTasksAsync(testId); + Assert.NotNull(subTasks); + Assert.Empty(subTasks); + + // Act & Assert: 创建任务 + var created = await service.CreateTaskAsync(new CreateTaskDto { Title = "测试任务" }); + Assert.NotNull(created); + + // Act & Assert: 更新任务 + var updated = await service.UpdateTaskAsync(new UpdateTaskDto { Id = testId, Title = "已更新" }); + Assert.NotNull(updated); + + // Act & Assert: 切换完成状态 + var toggled = await service.ToggleCompleteAsync(testId); + Assert.NotNull(toggled); + + // Act & Assert: 删除任务(void 方法不抛异常即为成功) + await service.DeleteTaskAsync(testId); + } + + /// + /// 验证 MCP 工具可被调用(通过 McpServerTool.InvokeAsync)。 + /// 创建一个最小化的 RequestContext 并调用 task_get_all_tasks 工具。 + /// + [Fact] + public async Task WithDynamicApiTools_InvokeTaskGetAllTasks_ReturnsResult() + { + // Arrange + var services = new ServiceCollection(); + services.AddScoped(); + services.AddMcpServer() + .WithDynamicApiTools(); + + var provider = services.BuildServiceProvider(); + var tool = provider.GetServices() + .First(t => t.ProtocolTool.Name == "task_get_all_tasks"); + + // 通过 RuntimeHelpers 创建未初始化的 RequestContext, + // 然后手动注入 ServiceProvider(工具调用只需 Services 属性)。 + var ctxType = typeof(RequestContext<>).MakeGenericType(typeof(CallToolRequestParams)); + var requestContext = System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(ctxType) as RequestContext; + + Assert.NotNull(requestContext); + // 注入 ServiceProvider 以便 createTargetFunc 能解析 ITaskService + typeof(MessageContext).GetProperty("Services")!.SetValue(requestContext, provider); + + // Act + var result = await tool.InvokeAsync(requestContext!, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError == true, $"调用应成功"); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + } + #endregion #region 辅助方法(暴露 private 方法用于测试) @@ -311,14 +479,14 @@ public class MockTestServiceImpl : IMockTestService public class MockTaskService : ITaskService { public Task> GetAllTasksAsync() => Task.FromResult(new List()); - public Task GetTaskByIdAsync(int id) => Task.FromResult(null); + public Task GetTaskByIdAsync(Guid id) => Task.FromResult(null); public Task> GetActiveTasksAsync() => Task.FromResult(new List()); public Task> GetCompletedTasksAsync() => Task.FromResult(new List()); public Task CreateTaskAsync(CreateTaskDto dto) => Task.FromResult(new TaskDto()); public Task UpdateTaskAsync(UpdateTaskDto dto) => Task.FromResult(new TaskDto()); - public Task ToggleCompleteAsync(int id) => Task.FromResult(new TaskDto()); - public Task DeleteTaskAsync(int id) => Task.CompletedTask; - public Task> GetSubTasksAsync(int parentTaskId) => Task.FromResult(new List()); + public Task ToggleCompleteAsync(Guid id) => Task.FromResult(new TaskDto()); + public Task DeleteTaskAsync(Guid id) => Task.CompletedTask; + public Task> GetSubTasksAsync(Guid parentTaskId) => Task.FromResult(new List()); } #endregion diff --git a/src/Hua.Todo.Tests/Hua.Todo.Tests.csproj b/test/Hua.Todo.Host.Tests/Hua.Todo.Host.Tests.csproj similarity index 65% rename from src/Hua.Todo.Tests/Hua.Todo.Tests.csproj rename to test/Hua.Todo.Host.Tests/Hua.Todo.Host.Tests.csproj index 6eb6779..04eabc7 100644 --- a/src/Hua.Todo.Tests/Hua.Todo.Tests.csproj +++ b/test/Hua.Todo.Host.Tests/Hua.Todo.Host.Tests.csproj @@ -24,16 +24,9 @@ - - + + + - - - - - - - - - \ No newline at end of file + diff --git a/test/Hua.Todo.Host.Tests/Meeting/MeetingAiBreakdownTests.cs b/test/Hua.Todo.Host.Tests/Meeting/MeetingAiBreakdownTests.cs new file mode 100644 index 0000000..2ebd532 --- /dev/null +++ b/test/Hua.Todo.Host.Tests/Meeting/MeetingAiBreakdownTests.cs @@ -0,0 +1,432 @@ +using System.Reflection; +using Hua.Todo.Application.Services.Meeting; +using Hua.Todo.Application.Services.Meeting.Models; +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Host.Tests.Meeting; + +/// +/// 工单 03-03 AI 任务拆分服务测试。 +/// +public class MeetingAiBreakdownTests +{ + [Fact] + public void MeetingTaskSuggestion_DefaultProperties() + { + var suggestion = new MeetingTaskSuggestion + { + Title = "整理需求文档", + Priority = TaskPriority.High, + Reason = "会议中提及需要在周五前完成" + }; + + Assert.Equal("整理需求文档", suggestion.Title); + Assert.Equal(TaskPriority.High, suggestion.Priority); + Assert.Equal("会议中提及需要在周五前完成", suggestion.Reason); + } + + [Fact] + public void MeetingTaskSuggestion_DefaultValues() + { + var suggestion = new MeetingTaskSuggestion(); + + Assert.Equal(string.Empty, suggestion.Title); + Assert.Equal(TaskPriority.Medium, suggestion.Priority); + Assert.Equal(string.Empty, suggestion.Reason); + } + + [Fact] + public void BreakdownRequest_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var request = new BreakdownRequest + { + TaskId = taskId, + Notes = "会议讨论了三个议题" + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Equal("会议讨论了三个议题", request.Notes); + } + + [Fact] + public void BreakdownRequest_NotesCanBeNull() + { + var taskId = Guid.NewGuid(); + var request = new BreakdownRequest + { + TaskId = taskId, + Notes = null + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Null(request.Notes); + } + + [Fact] + public void BreakdownResponse_ContainsCorrectData() + { + var suggestions = new List + { + new() { Title = "任务A", Priority = TaskPriority.High, Reason = "原因A" }, + new() { Title = "任务B", Priority = TaskPriority.Low, Reason = "原因B" } + }; + + var taskId = Guid.NewGuid(); + var response = new BreakdownResponse + { + TaskId = taskId, + Suggestions = suggestions + }; + + Assert.Equal(taskId, response.TaskId); + Assert.Equal(2, response.Suggestions.Count); + Assert.Equal("任务A", response.Suggestions[0].Title); + } + + [Fact] + public void BreakdownResponse_DefaultSuggestionsIsEmpty() + { + var response = new BreakdownResponse(); + + Assert.NotNull(response.Suggestions); + Assert.Empty(response.Suggestions); + } + + [Fact] + public void SubTaskCreateItem_CanBeCreated() + { + var item = new SubTaskCreateItem + { + Title = "子任务标题", + Priority = TaskPriority.High + }; + + Assert.Equal("子任务标题", item.Title); + Assert.Equal(TaskPriority.High, item.Priority); + } + + [Fact] + public void SubTaskCreateItem_DefaultPriorityIsMedium() + { + var item = new SubTaskCreateItem(); + + Assert.Equal(TaskPriority.Medium, item.Priority); + } + + [Fact] + public void ConfirmBreakdownRequest_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var request = new ConfirmBreakdownRequest + { + TaskId = taskId, + SubTasks = new List + { + new() { Title = "任务1" }, + new() { Title = "任务2", Priority = TaskPriority.High } + } + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Equal(2, request.SubTasks.Count); + Assert.Equal("任务1", request.SubTasks[0].Title); + Assert.Equal(TaskPriority.High, request.SubTasks[1].Priority); + } + + [Fact] + public void ConfirmBreakdownRequest_DefaultSubTasksIsEmpty() + { + var request = new ConfirmBreakdownRequest(); + + Assert.NotNull(request.SubTasks); + Assert.Empty(request.SubTasks); + } + + [Fact] + public void BatchCreateResult_CanBeCreated() + { + var result = new BatchCreateResult + { + CreatedCount = 3, + SubTasks = new List + { + new() { Id = Guid.NewGuid(), Title = "任务1" }, + new() { Id = Guid.NewGuid(), Title = "任务2" }, + new() { Id = Guid.NewGuid(), Title = "任务3" } + } + }; + + Assert.Equal(3, result.CreatedCount); + Assert.Equal(3, result.SubTasks.Count); + Assert.Equal("任务1", result.SubTasks[0].Title); + } + + [Fact] + public void BatchCreateResult_DefaultSubTasksIsEmpty() + { + var result = new BatchCreateResult(); + + Assert.Equal(0, result.CreatedCount); + Assert.NotNull(result.SubTasks); + Assert.Empty(result.SubTasks); + } + + /// + /// 测试 ParseBreakdownJson 解析有效的 LLM JSON 响应。 + /// + [Fact] + public void ParseBreakdownJson_ValidJson_ReturnsSuggestions() + { + string json = """ + { + "suggestions": [ + { + "title": "整理需求文档", + "priority": 1, + "reason": "会议中决定需要整理" + }, + { + "title": "安排评审会议", + "priority": 2, + "reason": "下周前需要完成评审" + } + ] + } + """; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Equal(2, result.Count); + Assert.Equal("整理需求文档", result[0].Title); + Assert.Equal(TaskPriority.Medium, result[0].Priority); + Assert.Equal("会议中决定需要整理", result[0].Reason); + Assert.Equal("安排评审会议", result[1].Title); + Assert.Equal(TaskPriority.High, result[1].Priority); + Assert.Equal("下周前需要完成评审", result[1].Reason); + } + + /// + /// 测试 ParseBreakdownJson 解析被 markdown 代码块包裹的 LLM 响应。 + /// + [Fact] + public void ParseBreakdownJson_MarkdownWrapped_StripsMarkdown() + { + string json = """ + ```json + { + "suggestions": [ + { + "title": "更新项目计划", + "priority": 1, + "reason": "里程碑已调整" + } + ] + } + ``` + """; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Single(result); + Assert.Equal("更新项目计划", result[0].Title); + } + + /// + /// 测试 ParseBreakdownJson 处理空建议列表。 + /// + [Fact] + public void ParseBreakdownJson_EmptySuggestions_ReturnsEmptyList() + { + string json = """{"suggestions":[]}"""; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Empty(result); + } + + /// + /// 测试 ParseBreakdownJson 过滤标题为空的条目。 + /// + [Fact] + public void ParseBreakdownJson_FiltersEmptyTitle() + { + string json = """ + { + "suggestions": [ + { + "title": "", + "priority": 1, + "reason": "空标题" + }, + { + "title": "有效任务", + "priority": 0, + "reason": "有标题" + } + ] + } + """; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Single(result); + Assert.Equal("有效任务", result[0].Title); + } + + /// + /// 测试 ParseBreakdownJson 过滤空白标题的条目。 + /// + [Fact] + public void ParseBreakdownJson_FiltersWhitespaceTitle() + { + string json = """ + { + "suggestions": [ + { + "title": " ", + "priority": 1, + "reason": "空白标题" + }, + { + "title": "有效任务", + "priority": 0, + "reason": "有标题" + } + ] + } + """; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Single(result); + Assert.Equal("有效任务", result[0].Title); + } + + /// + /// 测试 ParseBreakdownJson 处理无效 JSON。 + /// + [Fact] + public void ParseBreakdownJson_InvalidJson_ReturnsEmptyList() + { + string json = "这不是有效的 JSON"; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Empty(result); + } + + /// + /// 测试 ParseBreakdownJson 处理空字符串。 + /// + [Fact] + public void ParseBreakdownJson_EmptyString_ReturnsEmptyList() + { + var result = MeetingAiBreakdownService.ParseBreakdownJson(""); + + Assert.Empty(result); + } + + /// + /// 测试 ParseBreakdownJson 返回的条目标题已 Trim。 + /// + [Fact] + public void ParseBreakdownJson_TrimsTitleWhitespace() + { + string json = """ + { + "suggestions": [ + { + "title": " 前后空格 ", + "priority": 1, + "reason": "测试" + } + ] + } + """; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Single(result); + Assert.Equal("前后空格", result[0].Title); + } + + /// + /// 测试 ParseBreakdownJson 处理 Reason 为 null 的条目。 + /// + [Fact] + public void ParseBreakdownJson_NullReason_ReturnsEmptyReason() + { + string json = """ + { + "suggestions": [ + { + "title": "测试任务", + "priority": 1 + } + ] + } + """; + + var result = MeetingAiBreakdownService.ParseBreakdownJson(json); + + Assert.Single(result); + Assert.Equal("测试任务", result[0].Title); + Assert.Equal(string.Empty, result[0].Reason); + } + + /// + /// 测试 IMeetingService 接口包含 RequestBreakdownAsync 方法。 + /// + [Fact] + public void IMeetingService_HasRequestBreakdownAsyncMethod() + { + var method = typeof(IMeetingService).GetMethod("RequestBreakdownAsync"); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + /// + /// 测试 IMeetingService 接口包含 ConfirmBreakdownAsync 方法。 + /// + [Fact] + public void IMeetingService_HasConfirmBreakdownAsyncMethod() + { + var method = typeof(IMeetingService).GetMethod("ConfirmBreakdownAsync"); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + /// + /// 测试 MeetingAiBreakdownService 类型存在并具有所需方法。 + /// + [Fact] + public void MeetingAiBreakdownService_HasAnalyzeAsyncMethod() + { + var method = typeof(MeetingAiBreakdownService).GetMethod("AnalyzeAsync"); + Assert.NotNull(method); + } + + /// + /// 测试 MeetingAiBreakdownService 类型存在并具有 ConfirmAndCreateAsync 方法。 + /// + [Fact] + public void MeetingAiBreakdownService_HasConfirmAndCreateAsyncMethod() + { + var method = typeof(MeetingAiBreakdownService).GetMethod("ConfirmAndCreateAsync"); + Assert.NotNull(method); + } + + /// + /// 测试 MeetingAiBreakdownService 类型存在并具有 ParseBreakdownJson 公开静态方法。 + /// + [Fact] + public void MeetingAiBreakdownService_HasParseBreakdownJsonMethod() + { + var method = typeof(MeetingAiBreakdownService).GetMethod("ParseBreakdownJson", + BindingFlags.Public | BindingFlags.Static); + Assert.NotNull(method); + Assert.True(method!.IsStatic); + } +} diff --git a/test/Hua.Todo.Host.Tests/Meeting/MeetingModelTests.cs b/test/Hua.Todo.Host.Tests/Meeting/MeetingModelTests.cs new file mode 100644 index 0000000..d0fb160 --- /dev/null +++ b/test/Hua.Todo.Host.Tests/Meeting/MeetingModelTests.cs @@ -0,0 +1,158 @@ +using Hua.Todo.Application.Services.Meeting.Models; +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Host.Tests.Meeting; + +/// +/// 工单 03-01 会议数据模型与 API 测试。 +/// +public class MeetingModelTests +{ + [Fact] + public void TaskType_Normal_IsZero() + { + Assert.Equal(0, (int)TaskType.Normal); + } + + [Fact] + public void TaskType_Meeting_IsOne() + { + Assert.Equal(1, (int)TaskType.Meeting); + } + + [Fact] + public void TaskEntity_DefaultTaskType_IsNormal() + { + var entity = new TaskEntity(); + Assert.Equal(TaskType.Normal, entity.TaskType); + } + + [Fact] + public void TaskEntity_DefaultMeetingNotes_IsNull() + { + var entity = new TaskEntity(); + Assert.Null(entity.MeetingNotes); + } + + [Fact] + public void TaskEntity_DefaultAudioDuration_IsNull() + { + var entity = new TaskEntity(); + Assert.Null(entity.AudioDuration); + } + + [Fact] + public void TaskEntity_MeetingFields_CanBeSet() + { + var entity = new TaskEntity + { + TaskType = TaskType.Meeting, + MeetingNotes = "会议讨论了三个议题", + AudioDuration = 1830.5 + }; + + Assert.Equal(TaskType.Meeting, entity.TaskType); + Assert.Equal("会议讨论了三个议题", entity.MeetingNotes); + Assert.Equal(1830.5, entity.AudioDuration); + } + + [Fact] + public void SaveNotesRequest_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var request = new SaveNotesRequest + { + TaskId = taskId, + Notes = "会议纪要内容" + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Equal("会议纪要内容", request.Notes); + } + + [Fact] + public void SaveNotesResponse_ContainsCorrectData() + { + var taskId = Guid.NewGuid(); + var response = new SaveNotesResponse + { + TaskId = taskId, + MeetingNotes = "已保存的纪要" + }; + + Assert.Equal(taskId, response.TaskId); + Assert.Equal("已保存的纪要", response.MeetingNotes); + } + + [Fact] + public void MeetingDetailResponse_ContainsCorrectData() + { + var taskId = Guid.NewGuid(); + var response = new MeetingDetailResponse + { + TaskId = taskId, + Title = "周一产品评审会", + MeetingNotes = "纪要内容", + AudioDuration = 900.0, + IsCompleted = false + }; + + Assert.Equal(taskId, response.TaskId); + Assert.Equal("周一产品评审会", response.Title); + Assert.Equal("纪要内容", response.MeetingNotes); + Assert.Equal(900.0, response.AudioDuration); + Assert.False(response.IsCompleted); + } + + [Fact] + public void TaskEntity_MeetingType_CanBeChildTask() + { + var parentId = Guid.NewGuid(); + var parent = new TaskEntity + { + Id = parentId, + Title = "周一例会", + TaskType = TaskType.Meeting + }; + + var child = new TaskEntity + { + Id = Guid.NewGuid(), + Title = "准备PPT", + ParentTaskId = parentId, + ParentTask = parent + }; + + parent.SubTasks.Add(child); + + Assert.Single(parent.SubTasks); + Assert.Equal(TaskType.Meeting, parent.TaskType); + Assert.Equal(parentId, child.ParentTaskId); + } + + [Fact] + public void TaskType_IsDefinedOnEntity() + { + // 验证 TaskType 是 TaskEntity 的有效属性 + var property = typeof(TaskEntity).GetProperty("TaskType"); + Assert.NotNull(property); + Assert.Equal(typeof(TaskType), property.PropertyType); + } + + [Fact] + public void MeetingNotes_IsDefinedOnEntity() + { + var property = typeof(TaskEntity).GetProperty("MeetingNotes"); + Assert.NotNull(property); + Assert.Equal(typeof(string), property.PropertyType); + } + + [Fact] + public void AudioDuration_IsDefinedOnEntity() + { + var property = typeof(TaskEntity).GetProperty("AudioDuration"); + Assert.NotNull(property); + Assert.Equal(typeof(double?), property.PropertyType); + } +} diff --git a/test/Hua.Todo.Host.Tests/Meeting/MeetingTranscriptionTests.cs b/test/Hua.Todo.Host.Tests/Meeting/MeetingTranscriptionTests.cs new file mode 100644 index 0000000..1f762fb --- /dev/null +++ b/test/Hua.Todo.Host.Tests/Meeting/MeetingTranscriptionTests.cs @@ -0,0 +1,107 @@ +using Hua.Todo.Application.Services.Meeting; +using Hua.Todo.Application.Services.Meeting.Models; +using Xunit; + +namespace Hua.Todo.Host.Tests.Meeting; + +/// +/// 工单 03-02 音频录制与转写测试。 +/// +public class MeetingTranscriptionTests +{ + [Fact] + public void TranscribeRequest_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var request = new TranscribeRequest + { + TaskId = taskId, + AudioBase64 = "dGVzdA==", + Format = "webm", + AudioDuration = 90.0 + }; + + Assert.Equal(taskId, request.TaskId); + Assert.Equal("dGVzdA==", request.AudioBase64); + Assert.Equal("webm", request.Format); + Assert.Equal(90.0, request.AudioDuration); + } + + [Fact] + public void TranscribeResponse_CanBeCreated() + { + var taskId = Guid.NewGuid(); + var response = new TranscribeResponse + { + TaskId = taskId, + Transcript = "这是转写后的文字内容", + AudioDuration = 90.0 + }; + + Assert.Equal(taskId, response.TaskId); + Assert.Equal("这是转写后的文字内容", response.Transcript); + Assert.Equal(90.0, response.AudioDuration); + } + + [Fact] + public void TranscribeResponse_DefaultValues() + { + var response = new TranscribeResponse(); + + Assert.Equal(Guid.Empty, response.TaskId); + Assert.Equal(string.Empty, response.Transcript); + Assert.Equal(0.0, response.AudioDuration); + } + + [Fact] + public void TranscribeRequest_DefaultValues() + { + var request = new TranscribeRequest(); + + Assert.Equal(Guid.Empty, request.TaskId); + Assert.Equal(string.Empty, request.AudioBase64); + Assert.Equal(string.Empty, request.Format); + Assert.Equal(0.0, request.AudioDuration); + } + + [Fact] + public void ISttService_InterfaceExists() + { + var type = typeof(ISttService); + Assert.True(type.IsInterface); + Assert.Contains("Hua.Todo.Application.Services.Meeting", type.Namespace); + } + + [Fact] + public void SttService_ImplementsISttService() + { + var type = typeof(SttService); + Assert.True(typeof(ISttService).IsAssignableFrom(type)); + } + + [Fact] + public void ISttService_HasTranscribeAsyncMethod() + { + var method = typeof(ISttService).GetMethod("TranscribeAsync"); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method.ReturnType); + + var parameters = method.GetParameters(); + Assert.Equal(3, parameters.Length); + Assert.Equal(typeof(Stream), parameters[0].ParameterType); + Assert.Equal(typeof(string), parameters[1].ParameterType); + Assert.Equal(typeof(CancellationToken), parameters[2].ParameterType); + } + + [Fact] + public void IMeetingService_HasTranscribeAudioAsyncMethod() + { + var method = typeof(IMeetingService).GetMethod("TranscribeAudioAsync"); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method.ReturnType); + + var parameters = method.GetParameters(); + Assert.Single(parameters); + Assert.Equal(typeof(TranscribeRequest), parameters[0].ParameterType); + } +} diff --git a/src/Hua.Todo.Tests/TaskEntityTests.cs b/test/Hua.Todo.Host.Tests/TaskEntityTests.cs similarity index 98% rename from src/Hua.Todo.Tests/TaskEntityTests.cs rename to test/Hua.Todo.Host.Tests/TaskEntityTests.cs index 84bbc05..606d8a1 100644 --- a/src/Hua.Todo.Tests/TaskEntityTests.cs +++ b/test/Hua.Todo.Host.Tests/TaskEntityTests.cs @@ -1,7 +1,7 @@ using Hua.Todo.Core.Entities; using Xunit; -namespace Hua.Todo.Tests; +namespace Hua.Todo.Host.Tests; /// /// TaskEntity 单元测试。 @@ -120,4 +120,4 @@ public class FullAuditedEntityWithUserTests private class TestAuditedEntity : FullAuditedEntityWithUser { } -} \ No newline at end of file +} diff --git a/test/Hua.Todo.Host.Tests/VoiceIntentParserTests.cs b/test/Hua.Todo.Host.Tests/VoiceIntentParserTests.cs new file mode 100644 index 0000000..45f278d --- /dev/null +++ b/test/Hua.Todo.Host.Tests/VoiceIntentParserTests.cs @@ -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; + +/// +/// 语音意图解析与命令执行器单元测试。 +/// 覆盖 RuleIntentParser(离线规则解析)、VoiceCommandExecutor(命令执行)、模型序列化。 +/// +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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.CREATE, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.CREATE, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.DELETE, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.COMPLETE, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.DELETE, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.DELETE, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.ADD_SUBTASK, + Params = new Dictionary { ["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.Instance); + + var intentResult = new VoiceIntentResult + { + Intent = VoiceIntent.UNCOMPLETE, + Params = new Dictionary { ["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.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.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.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.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.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.Instance); + + var confirmParentId = Guid.NewGuid(); + var confirmRequest = new AiBreakdownConfirmRequest + { + ParentTaskId = confirmParentId, + SubTasks = new List + { + 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 实现 + +/// +/// Mock ITaskService 实现,用于 VoiceCommandExecutor 和 AiBreakdownService 测试。 +/// 跟踪创建、删除操作并支持预设任务数据。 +/// +public class MockTaskServiceForVoice : ITaskService +{ + private readonly List _tasks = new(); + private Guid _nextId = Guid.NewGuid(); + + /// + /// 跟踪已创建的任务。 + /// + public List CreatedTasks { get; } = new(); + + /// + /// 跟踪已删除的 ID。 + /// + public List DeletedIds { get; } = new(); + + /// + /// 预设一个已有任务,返回创建的任务 ID 供后续断言使用。 + /// + 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() + }); + return id; + } + + public Task> GetAllTasksAsync() => Task.FromResult(_tasks.ToList()); + public Task GetTaskByIdAsync(Guid id) => Task.FromResult(_tasks.FirstOrDefault(t => t.Id == id)); + public Task> GetActiveTasksAsync() => Task.FromResult(_tasks.Where(t => !t.IsCompleted).ToList()); + public Task> GetCompletedTasksAsync() => Task.FromResult(_tasks.Where(t => t.IsCompleted).ToList()); + + public Task 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() + }; + _tasks.Add(task); + CreatedTasks.Add(task); + return Task.FromResult(task); + } + + public Task 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 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> GetSubTasksAsync(Guid parentTaskId) + { + return Task.FromResult(_tasks.Where(t => t.ParentTaskId == parentTaskId).ToList()); + } +} + +/// +/// Mock ILlmClientService 实现,返回预设文本或抛出异常。 +/// +public class MockLlmClientService : ILlmClientService +{ + private readonly string _presetResponse; + + /// + /// 创建 Mock LLM 客户端。 + /// + /// 预设的 LLM 响应文本。 + public MockLlmClientService(string presetResponse) + { + _presetResponse = presetResponse; + } + + /// + /// 返回预设响应(或抛出异常以模拟 LLM 不可用)。 + /// + public Task SendAsync(string prompt, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_presetResponse)) + throw new HttpRequestException("LLM unavailable"); + + return Task.FromResult(_presetResponse); + } +} + +#endregion diff --git a/test/Hua.Todo.Maui.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs b/test/Hua.Todo.Maui.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs new file mode 100644 index 0000000..0aad7aa --- /dev/null +++ b/test/Hua.Todo.Maui.Tests/CloudSync/CloudSyncProxyMiddlewareTests.cs @@ -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; + +/// +/// CloudSyncProxyMiddleware 行为集成测试(MAUI 端)。 +/// 验证云同步路径代理转发、无 URL 时返回 503。 +/// +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(); + } +} diff --git a/test/Hua.Todo.Maui.Tests/DatabaseInitializationTests.cs b/test/Hua.Todo.Maui.Tests/DatabaseInitializationTests.cs new file mode 100644 index 0000000..3b1d160 --- /dev/null +++ b/test/Hua.Todo.Maui.Tests/DatabaseInitializationTests.cs @@ -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; + +/// +/// 数据库初始化集成测试,覆盖 MauiProgram.InitializeDatabase 的 Migrate → EnsureCreated 回退链路。 +/// 对应日志中 "no such table: T_Tasks" 错误场景,验证迁移链完整性与恢复能力。 +/// +public class DatabaseInitializationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public DatabaseInitializationTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + } + + public void Dispose() + { + _connection.Dispose(); + } + + private TodoDbContext CreateContext() + { + return new TodoDbContext(_options); + } + + /// + /// 模拟 MauiProgram.InitializeDatabase 的 Migrate() 路径: + /// 从零开始执行全部迁移,验证 T_Tasks 表存在。 + /// 对应日志第 1-21 行:Migrate() 失败场景。 + /// + [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); + } + + /// + /// 验证迁移后所有核心表存在。 + /// 对应日志第 23-27 行:查询 T_Tasks 失败场景。 + /// + [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); + } + + /// + /// 验证 T_Tasks 表包含日志中查询的所有列。 + /// 对应日志第 24-26 行 SELECT 查询中引用的所有字段。 + /// + [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); + } + + /// + /// 模拟 MauiProgram.InitializeDatabase 的 fallback 路径: + /// Migrate 失败 → 删除数据库 → EnsureCreated 重建。 + /// 对应日志第 1 行 "Database initialization failed" 后的恢复流程。 + /// + [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() + .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() + .UseSqlite(conn2) + .Options; + using var context2 = new TodoDbContext(options2); + + // Act: EnsureCreated(fallback 路径) + 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); + } + } + + /// + /// 验证 Migrate 是幂等的:第二次调用不会报错。 + /// 模拟多次启动 MauiProgram 的场景。 + /// + [Fact] + public void InitializeDatabase_Migrate_Idempotent() + { + using var context = CreateContext(); + context.Database.Migrate(); + + // 第二次 Migrate 不应抛出异常 + var exception = Record.Exception(() => context.Database.Migrate()); + Assert.Null(exception); + } + + /// + /// 验证迁移后可以执行日志中失败的查询。 + /// 对应日志第 23-27 行 SELECT 查询。 + /// + [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 == "测试"); + } + + /// + /// 验证迁移顺序正确:MakeTaskEntityAbpCompatible 在 AddMeetingAndDescriptionFields 之前执行。 + /// 这是日志中 "no such table: T_Tasks" 错误的根因验证。 + /// + [Fact] + public void InitializeDatabase_MigrationOrder_Correct() + { + using var context = CreateContext(); + context.Database.Migrate(); + + var history = context.GetService(); + 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 GetTableNames(TodoDbContext context) + { + return context.Database.SqlQueryRaw( + "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 GetColumnNames(TodoDbContext context, string tableName) + { + var columns = new List(); + 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; + } +} \ No newline at end of file diff --git a/test/Hua.Todo.Maui.Tests/Hua.Todo.Maui.Tests.csproj b/test/Hua.Todo.Maui.Tests/Hua.Todo.Maui.Tests.csproj new file mode 100644 index 0000000..87e3dd8 --- /dev/null +++ b/test/Hua.Todo.Maui.Tests/Hua.Todo.Maui.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +