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

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

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
This commit is contained in:
ShaoHua
2026-06-21 03:26:04 +08:00
parent 65cee20006
commit 4fe0b5a963
200 changed files with 12728 additions and 3550 deletions
+2
View File
@@ -366,3 +366,5 @@ FodyWeavers.xsd
/Hua.Todo/Output /Hua.Todo/Output
/src/Hua.Todo.Maui/Output /src/Hua.Todo.Maui/Output
/src/Hua.Todo.Host/Hua.Todo.db /src/Hua.Todo.Host/Hua.Todo.db
src/Hua.Todo.Host/Hua.Todo.db-wal
src/Hua.Todo.Host/Hua.Todo.db-shm
+1
View File
@@ -0,0 +1 @@
{}
@@ -63,7 +63,7 @@
| 子目录 | 当前最大序号 | 下一个可用 | | 子目录 | 当前最大序号 | 下一个可用 |
|---|---|---| |---|---|---|
| `rules/全局/` | 06 | 07 | | `rules/全局/` | 07 | 08 |
| `rules/项目/` | 05 | 06 | | `rules/项目/` | 05 | 06 |
| `memory/` | 01 | 02 | | `memory/` | 01 | 02 |
| `coordination/` | 02 | 03 | | `coordination/` | 02 | 03 |
+4 -2
View File
@@ -17,9 +17,11 @@ description: 强制项目注释规范(C# / TypeScript):新增或修改代
## C#.NET / MAUI ## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含: - 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途 - `summary`:一句话说明用途,不允许重复嵌套 `<summary>` 标签
-关键参数/返回值:`param` / `returns` - 对参数/返回值:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务) - 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
- XML 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明,避免 `///` 落在特性之后导致 CS1587。
- `<see cref="..."/>` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574。
-**跨平台逻辑** -**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。 - 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。 - 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。
@@ -138,4 +138,6 @@ NN-NN-标题.md
--- ---
**关联规则**[05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md) **关联规则**[05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md)、[07-代码实现与测试先行规范.md](./07-代码实现与测试先行规范.md)
> 工单进入代码实现阶段时,必须遵循"测试先行(ATDD)"工作流:先依据本规范的验收标准编写验收测试用例,再实现代码,测试全部通过后才算工单完成。详见 [07-代码实现与测试先行规范.md](./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)
+29
View File
@@ -73,3 +73,32 @@ Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主)
- Inno Setup 安装包:`src/Hua.Todo.Maui/setup.iss``src/Hua.Todo.Avalonia/setup.iss` - Inno Setup 安装包:`src/Hua.Todo.Maui/setup.iss``src/Hua.Todo.Avalonia/setup.iss`
- Linux`publish-linux.ps1` 产出 `.tar.gz``pack/linux/` 含 Flatpak 基础结构 - 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) - 详见 [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.{模块名}`
- 文件组织:每个模块新建子目录,模块级共用测试放根目录
+76 -2
View File
@@ -42,8 +42,8 @@
| 03-01 - 会议数据模型与 API | 待开始 | 待验证 | TaskType 枚举、MeetingNotes/AudioDuration 字段、MeetingController | | 03-01 - 会议数据模型与 API | 待开始 | 待验证 | TaskType 枚举、MeetingNotes/AudioDuration 字段、MeetingController |
| 03-02 - 音频录制与转写 | 待开始 | 待验证 | 前端 MediaRecorder 录音 + 后端 STT 转写 | | 03-02 - 音频录制与转写 | 待开始 | 待验证 | 前端 MediaRecorder 录音 + 后端 STT 转写 |
| 03-03 - AI 任务拆分服务 | 待开始 | 待验证 | 会议专用 LLM prompt + 批量创建子任务 | | 03-03 - AI 任务拆分服务 | 待开始 | 待验证 | 会议专用 LLM prompt + 批量创建子任务 |
| 03-04 | 任务建议与确认 UI | 待开始 | 待验证 | 录音/纪要/审阅对话框 + Meeting 类型条件渲染 | | 03-04 | 任务建议与确认 UI | 已完成 | 待验证 | MeetingBreakdownDialog.vue + meeting.ts 拆分/确认 API;待集成到 TaskItem |
| 04 | 富文本描述、附件与外部链接 | 待开始 | 待验证 | 多行描述 + 附件上传下载删除 + 外部链接 + 桌面端 Process.Start/xdg-open | | 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 工具;当前覆盖 ITaskService9 个工具)+ IVoiceService4 个工具)= 13 个 MCP 工具;新增 4 个测试(描述验证、InputSchema 验证、服务调用、工具调用端到端),共计 17 个测试全部通过;CloudSync 服务因未实现 IDynamicApiService 暂未覆盖,记录为已知缺口 - [x] v1.3.0 工单01 - MCP 服务转换已完成(2026-06-16):DynamicMcpToolExtensions 自动扫描所有 IDynamicApiService 接口并生成 MCP 工具;当前覆盖 ITaskService9 个工具)+ IVoiceService4 个工具)= 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): - **术语统一与目录中文化**(2026-06):
@@ -77,6 +104,53 @@
- 拆分用户面与开发者面:01-02 为用户安装使用;05-10 为开发者架构/构建/规范 - 拆分用户面与开发者面:01-02 为用户安装使用;05-10 为开发者架构/构建/规范
- 合并重叠与过时内容,精简用户文档篇幅 - 合并重叠与过时内容,精简用户文档篇幅
- 同步更新 README.md 与 `.trae/rules/项目/` 中的交叉引用 - 同步更新 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``<ProjectReference>` 路径 + `.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 个测试全部通过
--- ---
+3 -1
View File
@@ -38,6 +38,7 @@
| [04-研发工单全流程规范.md](./rules/全局/04-研发工单全流程规范.md) | 研发工单术语定义 + 拆分输出规范 + 新增工单约束(合并原 05/06/09) | | [04-研发工单全流程规范.md](./rules/全局/04-研发工单全流程规范.md) | 研发工单术语定义 + 拆分输出规范 + 新增工单约束(合并原 05/06/09) |
| [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 | | [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 |
| [06-AI沟通记录规范.md](./rules/全局/06-AI沟通记录规范.md) | 用户与智能体沟通记录的存储目录、序号管理与内容规范 | | [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` 等代码标识符与"研发工单"边界 | | [02-业务命名规范.md](./rules/项目/02-业务命名规范.md) | `Task`/`SubTask`/`TaskEntity` 等代码标识符与"研发工单"边界 |
| [03-数据模型与迁移约束.md](./rules/项目/03-数据模型与迁移约束.md) | EF Core 实体清单、迁移历史、改 schema 纪律 | | [03-数据模型与迁移约束.md](./rules/项目/03-数据模型与迁移约束.md) | EF Core 实体清单、迁移历史、改 schema 纪律 |
| [04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) | 当前活跃版本 / 工单状态快照 / 临时决策 / 未完结事项 | | [04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) | 当前活跃版本 / 工单状态快照 / 临时决策 / 未完结事项 |
| [05-多入口功能同步规范.md](./rules/项目/05-多入口功能同步规范.md) | 新增功能时必须同步确认 UI 入口与语音控制入口的覆盖情况 | | [05-多入口功能同步规范.md](./rules/项目/05-多入口功能同步规范.md) | 新增功能时必须同步确认 UI 入口与语音控制入口的覆盖情况 |
| [06-测试项目分层规范.md](./rules/项目/06-测试项目分层规范.md) | 测试项目目录结构、分层规则(禁止跨层)、技术栈与命名规范 |
--- ---
+4 -1
View File
@@ -14,8 +14,11 @@
<!-- 编译优化选项 --> <!-- 编译优化选项 -->
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio> <AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现文件正由另一进程使用 (XARDF7024) 的问题。 --> <!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现"文件正由另一进程使用" (XARDF7024) 的问题。 -->
<MSBuildDisableNodeReuse>true</MSBuildDisableNodeReuse> <MSBuildDisableNodeReuse>true</MSBuildDisableNodeReuse>
<!-- 禁止 SDK 自动生成 AssemblyInfo,避免与 Directory.Build.props 中的元数据属性冲突(CS0579)。 -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+16 -4
View File
@@ -5,14 +5,26 @@
<Platform Name="x64" /> <Platform Name="x64" />
<Platform Name="x86" /> <Platform Name="x86" />
</Configurations> </Configurations>
<Folder Name="/src/"> <Folder Name="/src/" />
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj"> <Folder Name="/src/Domain/">
<Build Solution="Debug|*" Project="true" /> <Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj" />
</Project>
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" /> <Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
</Folder>
<Folder Name="/src/Host/">
<Project Path="src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj" />
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" /> <Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj"> <Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
<Build Solution="Debug|*" Project="false" /> <Build Solution="Debug|*" Project="false" />
</Project> </Project>
</Folder> </Folder>
<Folder Name="/src/HttpApi/">
<Project Path="src/Hua.Todo.HttpApi/Hua.Todo.HttpApi.csproj" />
<Project Path="src/Hua.Todo.HttpApi.Android/Hua.Todo.HttpApi.Android.csproj" />
<Project Path="src/Hua.Todo.HttpApi.AspNetCore/Hua.Todo.HttpApi.AspNetCore.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/Hua.Todo.Host.Tests/Hua.Todo.Host.Tests.csproj" />
<Project Path="test/Hua.Todo.Maui.Tests/Hua.Todo.Maui.Tests.csproj" />
<Project Path="test/Hua.Todo.Avalonia.Tests/Hua.Todo.Avalonia.Tests.csproj" />
</Folder>
</Solution> </Solution>
@@ -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) 同时运行时,需注意数据库隔离与端口分配。
+4 -1
View File
@@ -41,7 +41,10 @@ Hua.Todo/
│ ├── Hua.Todo.Maui/ # MAUI 客户端(Windows/macOS/Android/iOS │ ├── Hua.Todo.Maui/ # MAUI 客户端(Windows/macOS/Android/iOS
│ ├── Hua.Todo.Avalonia/ # Avalonia 客户端(Linux/Windows 桌面) │ ├── Hua.Todo.Avalonia/ # Avalonia 客户端(Linux/Windows 桌面)
│ ├── Hua.Todo.Web/ # Vue 3 前端(Vite │ ├── 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/ ├── docs/
│ ├── manual/ # 项目手册 │ ├── manual/ # 项目手册
│ ├── project/ # 产品需求文档与研发工单 │ ├── project/ # 产品需求文档与研发工单
+4 -3
View File
@@ -103,9 +103,10 @@ Hua.Todo/
│ │ ├── tsconfig.json │ │ ├── tsconfig.json
│ │ └── index.html │ │ └── index.html
│ │ │ │
│ └── Hua.Todo.Tests/ # 测试项目 │ └── test/ # 测试项目
│ ├── Unit/ │ ├── Hua.Todo.Host.Tests/ # Host 服务端测试
── Integration/ ── Hua.Todo.Maui.Tests/ # MAUI 客户端测试
│ └── Hua.Todo.Avalonia.Tests/ # Avalonia 客户端测试
├── publish.ps1 / publish-windows.ps1 / publish-linux.ps1 ├── publish.ps1 / publish-windows.ps1 / publish-linux.ps1
├── Directory.Build.props ├── Directory.Build.props
+7 -4
View File
@@ -14,9 +14,11 @@
### 2.2 注释规范 (强制) ### 2.2 注释规范 (强制)
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性) - **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
- ** summary**:一句话说明用途 - **summary**:一句话说明用途,不允许重复嵌套 `<summary>` 标签
- ** param / returns**关键参数/返回值说明 - **param / returns**构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns`
- **异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务) - **异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
- **XML 注释位置**`///` 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明
- **cref 使用**`<see cref="..."/>` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574 警告
- **复杂逻辑添加行内注释** - **复杂逻辑添加行内注释**
- **禁止在日志或注释中输出密钥、Token、用户隐私信息** - **禁止在日志或注释中输出密钥、Token、用户隐私信息**
@@ -92,10 +94,11 @@ using System.Threading.Tasks;
// 2. 命名空间 // 2. 命名空间
namespace Hua.Todo.Api.Services; namespace Hua.Todo.Api.Services;
// 3. XML 文档注释 // 3. XML 文档注释(必须位于特性和声明之前)
/// <summary> /// <summary>
/// 任务服务实现 /// 任务服务实现
/// </summary> /// </summary>
[SomeAttribute]
public class TaskService : ITaskService public class TaskService : ITaskService
{ {
// 4. 私有字段 // 4. 私有字段
@@ -22,10 +22,10 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 工单编号 | 标题 | 负责人 | 状态 | | 工单编号 | 标题 | 负责人 | 状态 |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| 01 | HTTP 服务转换为 MCP 服务 | - | 进行中 | | 01 | HTTP 服务转换为 MCP 服务 | - | 已完成 |
| 02 | 语音控制与 AI 辅助 | - | 待开始 | | 02 | 语音控制与 AI 辅助 | - | 已完成 |
| 03 | 会议任务拆分 | - | 待开始 | | 03 | 会议任务拆分 | - | 待开始 |
| 04 | 富文本描述、附件与外部链接 | - | 待开始 | | 04 | 富文本描述、附件与外部链接 | - | 已完成 |
### 2.2 03 子工单拆分 ### 2.2 03 子工单拆分
@@ -34,7 +34,7 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 03-01 | 会议数据模型与 API | 无 | 待开始 | | 03-01 | 会议数据模型与 API | 无 | 待开始 |
| 03-02 | 音频录制与转写 | 03-01 | 待开始 | | 03-02 | 音频录制与转写 | 03-01 | 待开始 |
| 03-03 | AI 任务拆分服务 | 03-01、工单 02 LlmClientService | 待开始 | | 03-03 | AI 任务拆分服务 | 03-01、工单 02 LlmClientService | 待开始 |
| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 待开始 | | 03-04 | 任务建议与确认 UI | 03-01、03-03 | 已完成 |
### 2.3 串行工单(依赖前置工单完成) ### 2.3 串行工单(依赖前置工单完成)
@@ -129,9 +129,10 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 子工单 | 验证项 | 状态 | 备注 | | 子工单 | 验证项 | 状态 | 备注 |
|---|---|---|---| |---|---|---|---|
| 01 | MCP 服务契约文档生成 | 验证 | - | | 01 | MCP 服务契约文档生成 | 验证 | 动态工具自动生成,工具名/描述/参数 schema 均已覆盖 |
| 01 | MCP 服务可用性测试 | 验证 | - | | 01 | MCP 服务可用性测试 | 验证 | 17 个单元测试全部通过(含工具调用端到端验证) |
| 01 | 原有 API 功能兼容性 | 验证 | - | | 01 | 原有 API 功能兼容性 | 验证 | 所有 ITaskService 方法均生成 MCP 工具,覆盖 CRUD 全部 9 个 API |
| 01 | CloudSync MCP 工具 | 已知缺口 | CloudSync 服务未实现 IDynamicApiService,需手动映射或重构 |
| 02 | STT/TTS 平台适配 | 待验证 | Windows 优先,其他平台后续 | | 02 | STT/TTS 平台适配 | 待验证 | Windows 优先,其他平台后续 |
| 02 | 语音指令识别准确率 | 待验证 | - | | 02 | 语音指令识别准确率 | 待验证 | - |
| 02 | 歧义处理正确性 | 待验证 | - | | 02 | 歧义处理正确性 | 待验证 | - |
@@ -140,7 +141,7 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 03 | 会议数据模型迁移 | 待验证 | TaskType 字段 + DB 迁移 | | 03 | 会议数据模型迁移 | 待验证 | TaskType 字段 + DB 迁移 |
| 03 | 录音与转写链路 | 待验证 | 录制 → 上传 → 转写 → 保存 | | 03 | 录音与转写链路 | 待验证 | 录制 → 上传 → 转写 → 保存 |
| 03 | AI 会议拆分质量 | 待验证 | 建议含标题+优先级+原因 | | 03 | AI 会议拆分质量 | 待验证 | 建议含标题+优先级+原因 |
| 03 | 建议审阅与批量创建 | 待验证 | 勾选/编辑/确认后创建子任务 | | 03 | 建议审阅与批量创建 | 待验证 | MeetingBreakdownDialog 已实现:勾选/编辑/确认后创建子任务 |
| 04 | 描述字段编辑与保存 | 待验证 | - | | 04 | 描述字段编辑与保存 | 待验证 | - |
| 04 | 附件上传/下载/删除 | 待验证 | - | | 04 | 附件上传/下载/删除 | 待验证 | - |
| 04 | 外部链接添加与打开 | 待验证 | - | | 04 | 外部链接添加与打开 | 待验证 | - |
+23
View File
@@ -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
+7
View File
@@ -57,7 +57,14 @@ if (-not (Test-Path ".\.codegraph")) {
} else { } else {
Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan
codegraph sync 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 失败" } if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" }
}
Write-Host "[完成] 增量同步完毕" -ForegroundColor Green Write-Host "[完成] 增量同步完毕" -ForegroundColor Green
} }
@@ -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;
/// <summary>
/// 云同步服务端 API 路由注册扩展。
/// </summary>
public static class CloudSyncEndpointExtensions
{
/// <summary>
/// 映射云同步相关 API 端点。
/// </summary>
/// <param name="app">Web 应用。</param>
/// <returns>Web 应用。</returns>
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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
/// <summary>
/// 注册云同步代理设置 API(仅嵌入式 WebServer 使用)。
/// GET /api/cloud-sync/settings → 返回当前 URL
/// POST /api/cloud-sync/settings → 设置 URL
/// </summary>
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<CloudSyncProxySettingsPersistence>() is { } persistence)
{
try { persistence.Save(settings.GetUrl() ?? string.Empty); } catch { /* 保存失败不影响前端 */ }
}
return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty });
});
return app;
}
}
@@ -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;
/// <summary>
/// 云同步反向代理中间件。
/// 将 /auth/、/tasks/、/sync/、/security/、/cloud-sync/ 请求转发到远程 Host。
/// 仅在嵌入式 MAUI/Avalonia WebServer 中注册。
/// </summary>
public class CloudSyncProxyMiddleware
{
private readonly RequestDelegate _next;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// 需要代理的路径前缀集合。
/// </summary>
private static readonly string[] ProxyPrefixes = { "/auth/", "/tasks/", "/sync/", "/security/", "/cloud-sync/" };
public CloudSyncProxyMiddleware(RequestDelegate next, IHttpClientFactory httpClientFactory)
{
_next = next;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// 处理请求。如果匹配代理前缀则转发,否则放行到下一中间件。
/// </summary>
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<CloudSyncProxySettings>();
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);
}
/// <summary>
/// 将当前请求转发到目标地址,并回写响应。
/// </summary>
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-TypeStringContent 已设置,但确保不被覆盖)
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);
}
}
/// <summary>
/// CloudSyncProxyMiddleware 的 IApplicationBuilder 扩展。
/// </summary>
public static class CloudSyncProxyMiddlewareExtensions
{
/// <summary>
/// 注册云同步反向代理中间件。
/// </summary>
public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder)
{
return builder.UseMiddleware<CloudSyncProxyMiddleware>();
}
}
@@ -1,34 +0,0 @@
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步代理 URL 管理服务。
/// 嵌入式 WebServer 启动时从 appsettings 读取初始值,
/// 前端通过 /api/cloud-sync/settings 读写。
/// </summary>
public class CloudSyncProxySettings
{
private string? _url;
private readonly object _lock = new();
/// <summary>
/// 获取当前配置的云同步服务端地址,未配置时返回 null。
/// </summary>
public string? GetUrl()
{
lock (_lock)
{
return _url;
}
}
/// <summary>
/// 设置云同步服务端地址。
/// </summary>
public void SetUrl(string? url)
{
lock (_lock)
{
_url = string.IsNullOrWhiteSpace(url) ? null : url.TrimEnd('/');
}
}
}
@@ -1,57 +0,0 @@
using System.Text.Json;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步代理 URL 的 appsettings.json 持久化帮助类。
/// 由 MAUI/Avalonia 宿主创建并注册,注入 appsettings 文件路径。
/// </summary>
public class CloudSyncProxySettingsPersistence
{
private readonly string _settingsFilePath;
public CloudSyncProxySettingsPersistence(string settingsFilePath)
{
_settingsFilePath = settingsFilePath;
}
/// <summary>
/// 将 CloudSyncUrl 写回 appsettings.json。
/// </summary>
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();
}
}
@@ -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;
/// <summary>
/// 云同步服务端能力的依赖注入扩展。
/// </summary>
public static class CloudSyncServiceCollectionExtensions
{
/// <summary>
/// 注册云同步相关的认证、授权与业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncServer(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<IRolePermissionMapper, DefaultRolePermissionMapper>();
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudAdminService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
// 注册探测服务专用的 HttpClient(仅限服务端,MAUI 端不注册 AddCloudSyncServer
// 关闭 SSL 证书校验:探测目标可能是自签证书或内网服务器
services.AddHttpClient("ProbeClient", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddScoped<CloudProbeService>();
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(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;
}
/// <summary>
/// 注册云同步反向代理所需的服务(轻量级,供嵌入式 MAUI/Avalonia WebServer 使用)。
/// 仅注册 URL 管理服务与转发专用 HttpClient,不包含认证/授权/业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services)
{
services.AddSingleton<CloudSyncProxySettings>();
// 代理专用的 HttpClient:不跟随重定向,允许转发到自签/内网服务器
services.AddHttpClient("CloudSyncProxy", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
return services;
}
}
@@ -1,48 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 错误响应生成器。
/// </summary>
public static class CloudApiErrors
{
/// <summary>
/// 生成标准错误响应。
/// </summary>
/// <param name="statusCode">HTTP 状态码。</param>
/// <param name="code">业务错误码。</param>
/// <param name="message">错误消息。</param>
/// <returns>最小 API 结果。</returns>
public static IResult Error(int statusCode, string code, string message)
{
return Results.Json(
new ApiErrorResponse { Code = code, Message = message },
statusCode: statusCode);
}
/// <summary>
/// 未认证。
/// </summary>
public static IResult Unauthorized(string message = "Unauthorized.")
=> Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message);
/// <summary>
/// 权限不足。
/// </summary>
public static IResult Forbidden(string message = "Forbidden.")
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message);
/// <summary>
/// 请求非法。
/// </summary>
public static IResult BadRequest(string message = "Bad request.")
=> Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message);
/// <summary>
/// 资源不存在。
/// </summary>
public static IResult NotFound(string message = "Not found.")
=> Error(StatusCodes.Status404NotFound, "NOT_FOUND", message);
}
@@ -1,11 +1,11 @@
using System.Globalization; using System.Globalization;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Data.Converters; namespace Hua.Todo.Application.Common.Converters;
/// <summary> /// <summary>
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601Round-trip)字符串形式持久化到 SQLite, /// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601Round-trip)字符串形式持久化到 SQLite,
/// 并在读取时兼容历史遗留的 ticks/Unix 时间戳 数字字符串,避免因脏数据导致查询失败。 /// 并在读取时兼容历史遗留的 "ticks/Unix 时间戳" 数字字符串,避免因脏数据导致查询失败。
/// </summary> /// </summary>
public sealed class LenientUtcDateTimeStringConverter : ValueConverter<DateTime, string> public sealed class LenientUtcDateTimeStringConverter : ValueConverter<DateTime, string>
{ {
@@ -0,0 +1,50 @@
using Serilog;
using Serilog.Events;
namespace Hua.Todo.Application.Common;
/// <summary>
/// Serilog 日志配置工厂。
/// 提供统一的日志输出模板和默认配置,由各宿主项目调用以初始化文件日志。
/// </summary>
public static class LoggingConfiguration
{
/// <summary>
/// 统一日志输出模板。
/// </summary>
private const string OutputTemplate =
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
/// <summary>
/// 创建基础 LoggerConfiguration。
/// 包含控制台输出(Debug 及以上)和按天滚动的文件输出(Information 及以上)。
/// 日志文件路径为 <paramref name="logDirectory"/> 下的 hua-todo-.log。
/// </summary>
/// <param name="logDirectory">日志文件目录路径。</param>
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);
}
}
@@ -1,13 +1,13 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Repositories; using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services; using Hua.Todo.Application.Services;
using Hua.Todo.Application.Services.Meeting;
using Hua.Todo.Application.Services.Voice;
using Hua.Todo.Core.Interfaces; 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;
/// <summary> /// <summary>
/// 应用层依赖注入扩展。 /// 应用层依赖注入扩展。
@@ -25,7 +25,13 @@ public static class ServiceCollectionExtensions
services.AddDbContext<TodoDbContext>(options => services.AddDbContext<TodoDbContext>(options =>
options.UseSqlite(connectionString, b => b.MigrationsAssembly("Hua.Todo.Application"))); options.UseSqlite(connectionString, b => b.MigrationsAssembly("Hua.Todo.Application")));
services.AddScoped<ITaskRepository, TaskRepository>(); services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IAttachmentRepository, AttachmentRepository>();
services.AddScoped<ITaskService, TaskService>(); services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IAttachmentService, AttachmentService>();
services.AddScoped<IMeetingService, MeetingService>();
services.AddScoped<MeetingAiBreakdownService>();
services.AddScoped<ISttService, SttService>();
services.AddVoiceServices();
return services; return services;
} }
@@ -1,48 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.Data;
/// <summary>
/// 应用程序数据库上下文(EF Core)。
/// </summary>
public class TodoDbContext : DbContext
{
/// <summary>
/// 创建 <see cref="TodoDbContext"/>。
/// </summary>
/// <param name="options">数据库上下文配置。</param>
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
/// <summary>
/// 任务集合。
/// </summary>
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TaskEntity>(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);
});
}
}
@@ -5,33 +5,34 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<!-- ASP.NET Core 仅 net10.0(移动端通过 csproj 排除依赖文件) -->
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'"> <ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<!-- 移动端排除依赖 ASP.NET Core 的 CloudSync I/O 文件 -->
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'"> <ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" /> <Compile Remove="Services\CloudSync\Services\CloudSyncProxyService.cs" />
<Compile Remove="Mcp\\**\\*.cs" /> <Compile Remove="Services\CloudSync\Services\CloudSyncServerService.cs" />
</ItemGroup> <Compile Remove="Services\CloudSync\Services\CloudSyncProxySettingsService.cs" />
<!-- 测试用:-p:SkipCloudSync=true 排除 CloudSync 编译(其依赖 TaskEntity ABP 重构未完成) -->
<ItemGroup Condition="'$(SkipCloudSync)' == 'true'">
<Compile Remove="CloudSync\\**\\*.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.5" />
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'"> <PackageReference Include="Serilog" Version="4.3.2-dev-02433" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" /> <ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" />
<ProjectReference Include="..\Hua.Todo.HttpApi\Hua.Todo.HttpApi.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,61 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,39 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')")
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tasks");
}
}
}
@@ -1,81 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("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
}
}
}
@@ -1,49 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddParentTaskId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
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);
}
/// <inheritdoc />
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");
}
}
}
@@ -1,198 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -1,136 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddCloudSyncCoreEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
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<Guid>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(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<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AllowPersist = table.Column<bool>(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<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StepUpExpiresAtUtc = table.Column<DateTime>(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);
}
/// <inheritdoc />
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");
}
}
}
@@ -1,203 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAllowSyncToSecurityPolicy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSync",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSync",
table: "SecurityPolicies");
}
}
}
@@ -1,219 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.HasColumnType("INTEGER");
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -1,63 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class UpdateSecurityEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAtUtc",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAtUtc",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
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");
}
}
}
@@ -1,269 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -1,87 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAuditLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: 30,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<bool>(
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<Guid>(type: "TEXT", nullable: false),
TimestampUtc = table.Column<string>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
UserName = table.Column<string>(type: "TEXT", nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ClientIp = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
IsSuccess = table.Column<bool>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.AlterColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 30);
migrationBuilder.AlterColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
oldClrType: typeof(bool),
oldType: "INTEGER",
oldDefaultValue: false);
}
}
}
@@ -1,273 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddPasswordSaltToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordSalt",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordSalt",
table: "Users");
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddMustChangePasswordToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MustChangePassword",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MustChangePassword",
table: "Users");
}
}
}
@@ -1,122 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
/// <summary>
/// 重构 TaskEntity 继承 ABP 风格审计基类。
/// - 主键从 int 改为 Guid
/// - ParentTaskId 从 int? 改为 Guid?
/// - 新增 ABP 审计字段:ExtraProperties, ConcurrencyStamp, CreationTime, CreatorId,
/// LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId
/// - 重命名 CreatedAt -> CreationTime, UpdatedAt -> LastModificationTime
/// - 表名从 Tasks 改为 T_Tasks
/// </summary>
public partial class MakeTaskEntityAbpCompatible : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. 创建临时表 T_Tasks(新结构)
migrationBuilder.CreateTable(
name: "T_Tasks",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ExtraProperties = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
CreationTime = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
CreatorId = table.Column<Guid>(type: "TEXT", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "TEXT", nullable: true),
LastModifierId = table.Column<Guid>(type: "TEXT", nullable: true),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
DeletionTime = table.Column<DateTime>(type: "TEXT", nullable: true),
DeleterId = table.Column<Guid>(type: "TEXT", nullable: true),
UserId = table.Column<Guid>(type: "TEXT", nullable: false, defaultValue: "local"),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
Code = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
ParentTaskId = table.Column<Guid>(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 -> GuidCreatedAt -> CreationTimeUpdatedAt -> 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
}
/// <inheritdoc />
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<int>(type: "INTEGER", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false, defaultValue: "local"),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
CreatedAt = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
UpdatedAt = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
ParentTaskId = table.Column<int>(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;");
}
}
}
@@ -1,78 +0,0 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,109 @@
using Hua.Todo.Core.Entities;
using System.Text.Json.Serialization;
namespace Hua.Todo.Application.Models;
/// <summary>
/// 附件返回 DTO。
/// </summary>
public class AttachmentDto
{
/// <summary>
/// 附件 ID。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 所属待办项 ID。
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 显示名称。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件路径或外部 URL。
/// </summary>
public string FilePath { get; set; } = string.Empty;
/// <summary>
/// 文件大小(字节),外部链接为 0。
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// MIME 类型。
/// </summary>
public string ContentType { get; set; } = string.Empty;
/// <summary>
/// 附件类型(0=本地文件, 1=外部链接)。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public AttachmentType AttachmentType { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAt { get; set; }
}
/// <summary>
/// 上传附件请求 DTO。
/// </summary>
public class UploadAttachmentRequest
{
/// <summary>
/// 所属待办项 ID。
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 原始文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Base64 编码的文件内容。
/// </summary>
public string Base64Content { get; set; } = string.Empty;
/// <summary>
/// 文件 MIME 类型。
/// </summary>
public string ContentType { get; set; } = string.Empty;
}
/// <summary>
/// 添加外部链接请求 DTO。
/// </summary>
public class AddLinkRequest
{
/// <summary>
/// 所属待办项 ID。
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 外部链接 URL,仅允许 http:// 或 https:// 协议。
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 链接显示名称,不传则使用 URL 作为名称。
/// </summary>
public string? FileName { get; set; }
}
/// <summary>
/// 打开附件响应 DTO。
/// </summary>
public class OpenAttachmentResponse
{
/// <summary>
/// 是否成功打开。
/// </summary>
public bool Opened { get; set; }
}
+69 -9
View File
@@ -13,38 +13,61 @@ public class CreateTaskDto
/// </summary> /// </summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary> /// <summary>
/// 任务优先级。 /// 任务优先级。
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium; public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary> /// <summary>
/// 父任务 ID(用于创建子任务)。 /// 父任务 ID(用于创建子任务)。
/// </summary> /// </summary>
public int? ParentTaskId { get; set; } public Guid? ParentTaskId { get; set; }
/// <summary>
/// 待办项类型(0=Normal, 1=Meeting),默认 Normal。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskType TaskType { get; set; } = TaskType.Normal;
/// <summary>
/// 多行描述文本,最大 5000 字符。
/// </summary>
public string? Description { get; set; }
} }
/// <summary> /// <summary>
/// 更新任务请求 DTO。 /// 更新任务请求 DTO。
/// 未传递的字段保持原值不变,传递 null 表示清空。
/// </summary> /// </summary>
public class UpdateTaskDto public class UpdateTaskDto
{ {
/// <summary> /// <summary>
/// 任务 ID。 /// 任务 ID。
/// </summary> /// </summary>
public int Id { get; set; } public Guid Id { get; set; }
/// <summary> /// <summary>
/// 新标题(可选)。 /// 新标题(可选,不传则保持原值)。
/// </summary> /// </summary>
public string? Title { get; set; } public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary> /// <summary>
/// 新优先级(可选)。 /// 新优先级(可选,不传则保持原值)。
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; } public TaskPriority? Priority { get; set; }
/// <summary>
/// 待办项类型(可选,不传则保持原值)。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskType? TaskType { get; set; }
/// <summary>
/// 多行描述文本(可选,不传则保持原值,传 null 则清空)。
/// </summary>
public string? Description { get; set; }
} }
/// <summary> /// <summary>
@@ -55,16 +78,22 @@ public class TaskDto
/// <summary> /// <summary>
/// 任务 ID。 /// 任务 ID。
/// </summary> /// </summary>
public int Id { get; set; } public Guid Id { get; set; }
/// <summary>
/// 任务编号(用户级自增字符串,用于展示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 任务标题。 /// 任务标题。
/// </summary> /// </summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary> /// <summary>
/// 任务优先级。 /// 任务优先级。
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } public TaskPriority Priority { get; set; }
/// <summary> /// <summary>
@@ -82,11 +111,42 @@ public class TaskDto
/// <summary> /// <summary>
/// 父任务 ID(可选)。 /// 父任务 ID(可选)。
/// </summary> /// </summary>
public int? ParentTaskId { get; set; } public Guid? ParentTaskId { get; set; }
/// <summary> /// <summary>
/// 子任务列表。 /// 子任务列表。
/// </summary> /// </summary>
public List<TaskDto> SubTasks { get; set; } = new(); public List<TaskDto> SubTasks { get; set; } = new();
/// <summary>
/// 待办项类型。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskType TaskType { get; set; }
/// <summary>
/// 会议纪要/转写文字。
/// </summary>
public string? MeetingNotes { get; set; }
/// <summary>
/// 录音时长(秒)。
/// </summary>
public double? AudioDuration { get; set; }
/// <summary>
/// 多行描述文本。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 附件列表(详情视图使用)。
/// </summary>
public List<AttachmentDto>? Attachments { get; set; }
/// <summary>
/// 附件数量(列表视图使用,不传完整附件列表)。
/// </summary>
public int? AttachmentCount { get; set; }
} }
/// <summary> /// <summary>
@@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Repositories;
/// <summary>
/// 附件仓储实现(EF Core)。
/// </summary>
public class AttachmentRepository : IAttachmentRepository
{
private readonly TodoDbContext _context;
/// <summary>
/// 创建 <see cref="AttachmentRepository"/>。
/// </summary>
/// <param name="context">数据库上下文。</param>
public AttachmentRepository(TodoDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<List<AttachmentEntity>> GetByTaskIdAsync(Guid taskId)
{
return await _context.Attachments
.Where(a => a.TaskId == taskId)
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
}
/// <inheritdoc />
public async Task<AttachmentEntity?> GetByIdAsync(Guid id)
{
return await _context.Attachments
.FirstOrDefaultAsync(a => a.Id == id);
}
/// <inheritdoc />
public async Task<AttachmentEntity> AddAsync(AttachmentEntity attachment)
{
_context.Attachments.Add(attachment);
await _context.SaveChangesAsync();
return attachment;
}
/// <inheritdoc />
public async Task DeleteAsync(Guid id)
{
var attachment = await _context.Attachments.FindAsync(id);
if (attachment != null)
{
_context.Attachments.Remove(attachment);
await _context.SaveChangesAsync();
}
}
/// <inheritdoc />
public async Task<int> GetCountByTaskIdAsync(Guid taskId)
{
return await _context.Attachments
.CountAsync(a => a.TaskId == taskId);
}
}
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Migrations namespace Hua.Todo.Application.Migrations
{ {
[DbContext(typeof(TodoDbContext))] [DbContext(typeof(TodoDbContext))]
[Migration("MakeTaskEntityAbpCompatible")] [Migration("20260616203619_InitialCreate")]
partial class MakeTaskEntityAbpCompatible partial class InitialCreate
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -20,6 +20,50 @@ namespace Hua.Todo.Application.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("AttachmentType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<Guid>("TaskId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TaskId");
b.ToTable("Attachments", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -43,8 +87,7 @@ namespace Hua.Todo.Application.Migrations
b.Property<bool>("IsSuccess") b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("TimestampUtc") b.Property<DateTime>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("UserAgent") b.Property<string>("UserAgent")
@@ -83,9 +126,7 @@ namespace Hua.Todo.Application.Migrations
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly") b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd() .HasColumnType("INTEGER");
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes") b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -109,6 +150,14 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<double?>("AudioDuration")
.HasColumnType("REAL");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp") b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -120,10 +169,14 @@ namespace Hua.Todo.Application.Migrations
b.Property<Guid?>("CreatorId") b.Property<Guid?>("CreatorId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletionTime") b.Property<DateTime?>("DeletionTime")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("DeleterId") b.Property<string>("Description")
.HasMaxLength(5000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExtraProperties") b.Property<string>("ExtraProperties")
@@ -134,10 +187,6 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -149,6 +198,10 @@ namespace Hua.Todo.Application.Migrations
b.Property<Guid?>("LastModifierId") b.Property<Guid?>("LastModifierId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("MeetingNotes")
.HasMaxLength(20000)
.HasColumnType("TEXT");
b.Property<Guid?>("ParentTaskId") b.Property<Guid?>("ParentTaskId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -157,15 +210,18 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(1); .HasDefaultValue(1);
b.Property<int>("TaskType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
.ValueGeneratedOnAdd() .HasColumnType("TEXT");
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id"); b.HasKey("Id");
@@ -223,15 +279,13 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc") b.Property<DateTime>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc") b.Property<DateTime>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc") b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
@@ -244,6 +298,17 @@ namespace Hua.Todo.Application.Migrations
b.ToTable("UserSessions", (string)null); 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 => modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{ {
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") 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 => modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{ {
b.Navigation("Attachments");
b.Navigation("SubTasks"); b.Navigation("SubTasks");
}); });
@@ -0,0 +1,225 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
TimestampUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
UserName = table.Column<string>(type: "TEXT", nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ClientIp = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
IsSuccess = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
PasswordSalt = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
MustChangePassword = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SecurityPolicies",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AllowPersist = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
AllowSync = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
SecondFactorExpiryMinutes = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 30),
IsTrustedDeviceOnly = table.Column<bool>(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<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
Code = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
ParentTaskId = table.Column<Guid>(type: "TEXT", nullable: true),
TaskType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
MeetingNotes = table.Column<string>(type: "TEXT", maxLength: 20000, nullable: true),
AudioDuration = table.Column<double>(type: "REAL", nullable: true),
Description = table.Column<string>(type: "TEXT", maxLength: 5000, nullable: true),
ExtraProperties = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
CreationTime = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
CreatorId = table.Column<Guid>(type: "TEXT", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "TEXT", nullable: true),
LastModifierId = table.Column<Guid>(type: "TEXT", nullable: true),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
DeletionTime = table.Column<DateTime>(type: "TEXT", nullable: true),
DeleterId = table.Column<Guid>(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<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StepUpExpiresAtUtc = table.Column<DateTime>(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<Guid>(type: "TEXT", nullable: false),
TaskId = table.Column<Guid>(type: "TEXT", nullable: false),
FileName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
FilePath = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: false),
FileSize = table.Column<long>(type: "INTEGER", nullable: false),
ContentType = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
AttachmentType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
CreatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -1,9 +1,8 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
@@ -11,15 +10,57 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Migrations namespace Hua.Todo.Application.Migrations
{ {
[DbContext(typeof(TodoDbContext))] [DbContext(typeof(TodoDbContext))]
[Migration("20260510171230_AddMustChangePasswordToUsers")] partial class TodoDbContextModelSnapshot : ModelSnapshot
partial class AddMustChangePasswordToUsers
{ {
/// <inheritdoc /> protected override void BuildModel(ModelBuilder modelBuilder)
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("AttachmentType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<Guid>("TaskId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TaskId");
b.ToTable("Attachments", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -43,8 +84,7 @@ namespace Hua.Todo.Application.Migrations
b.Property<bool>("IsSuccess") b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("TimestampUtc") b.Property<DateTime>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("UserAgent") b.Property<string>("UserAgent")
@@ -83,9 +123,7 @@ namespace Hua.Todo.Application.Migrations
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly") b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd() .HasColumnType("INTEGER");
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes") b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -105,44 +143,82 @@ namespace Hua.Todo.Application.Migrations
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{ {
b.Property<int>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("TEXT");
b.Property<string>("CreatedAt") b.Property<double?>("AudioDuration")
.HasColumnType("REAL");
b.Property<string>("Code")
.IsRequired() .IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreationTime")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')"); .HasDefaultValueSql("datetime('now')");
b.Property<Guid?>("CreatorId")
.HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(5000)
.HasColumnType("TEXT");
b.Property<string>("ExtraProperties")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted") b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<int?>("ParentTaskId") b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER"); .ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("TEXT");
b.Property<Guid?>("LastModifierId")
.HasColumnType("TEXT");
b.Property<string>("MeetingNotes")
.HasMaxLength(20000)
.HasColumnType("TEXT");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("TEXT");
b.Property<int>("Priority") b.Property<int>("Priority")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(1); .HasDefaultValue(1);
b.Property<int>("TaskType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
.ValueGeneratedOnAdd() .HasColumnType("TEXT");
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id"); b.HasKey("Id");
@@ -150,7 +226,7 @@ namespace Hua.Todo.Application.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("Tasks", (string)null); b.ToTable("T_Tasks", (string)null);
}); });
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
@@ -200,15 +276,13 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc") b.Property<DateTime>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc") b.Property<DateTime>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc") b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
@@ -221,6 +295,17 @@ namespace Hua.Todo.Application.Migrations
b.ToTable("UserSessions", (string)null); 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 => modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{ {
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") 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 => modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{ {
b.Navigation("Attachments");
b.Navigation("SubTasks"); b.Navigation("SubTasks");
}); });
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces; using Hua.Todo.Core.Interfaces;
@@ -28,7 +27,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetAllAsync() public async Task<List<TaskEntity>> GetAllAsync()
{ {
return await _context.Tasks return await _context.Tasks
.Include(t => t.SubTasks) .Include(t => t.SubTasks!)
.ToListAsync(); .ToListAsync();
} }
@@ -37,10 +36,10 @@ public class TaskRepository : ITaskRepository
/// </summary> /// </summary>
/// <param name="id">任务 ID。</param> /// <param name="id">任务 ID。</param>
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns> /// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
public async Task<TaskEntity?> GetByIdAsync(int id) public async Task<TaskEntity?> GetByIdAsync(Guid id)
{ {
return await _context.Tasks return await _context.Tasks
.Include(t => t.SubTasks) .Include(t => t.SubTasks!)
.FirstOrDefaultAsync(t => t.Id == id); .FirstOrDefaultAsync(t => t.Id == id);
} }
@@ -52,7 +51,7 @@ public class TaskRepository : ITaskRepository
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => !t.IsCompleted) .Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreationTime)
.ToListAsync(); .ToListAsync();
} }
@@ -64,7 +63,7 @@ public class TaskRepository : ITaskRepository
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => t.IsCompleted) .Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt) .OrderByDescending(t => t.LastModificationTime)
.ToListAsync(); .ToListAsync();
} }
@@ -87,7 +86,7 @@ public class TaskRepository : ITaskRepository
/// <returns>更新后的任务实体。</returns> /// <returns>更新后的任务实体。</returns>
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity) public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{ {
taskEntity.UpdatedAt = DateTime.UtcNow; taskEntity.LastModificationTime = DateTime.UtcNow;
_context.Tasks.Update(taskEntity); _context.Tasks.Update(taskEntity);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return taskEntity; return taskEntity;
@@ -98,7 +97,7 @@ public class TaskRepository : ITaskRepository
/// </summary> /// </summary>
/// <param name="id">要删除的任务 ID。</param> /// <param name="id">要删除的任务 ID。</param>
/// <returns>表示删除操作的任务。</returns> /// <returns>表示删除操作的任务。</returns>
public async Task DeleteAsync(int id) public async Task DeleteAsync(Guid id)
{ {
var task = await _context.Tasks.FindAsync(id); var task = await _context.Tasks.FindAsync(id);
if (task != null) if (task != null)
@@ -113,11 +112,35 @@ public class TaskRepository : ITaskRepository
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务 ID。</param> /// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表。</returns> /// <returns>子任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId) public async Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId)
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId) .Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreationTime)
.ToListAsync(); .ToListAsync();
} }
/// <summary>
/// 获取指定用户的任务中最大的数字型 Code。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <returns>最大数字 Code;若无任务或无可解析 Code 则返回 0。</returns>
public async Task<int> 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;
}
} }
@@ -0,0 +1,187 @@
using Hua.Todo.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace Hua.Todo.Application.Repositories;
/// <summary>
/// 应用程序数据库上下文(EF Core)。
/// </summary>
public class TodoDbContext : DbContext
{
/// <summary>
/// 创建 <see cref="TodoDbContext"/>。
/// </summary>
/// <param name="options">数据库上下文配置。</param>
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
/// <summary>
/// 配置 DbContext 行为,抑制开发期间模型变化导致的迁移验证警告。
/// </summary>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
}
/// <summary>
/// 任务集合。
/// </summary>
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 附件集合。
/// </summary>
public DbSet<AttachmentEntity> Attachments { get; set; }
/// <summary>
/// 用户集合(云同步)。
/// </summary>
public DbSet<UserEntity> Users { get; set; }
/// <summary>
/// 用户会话集合(云同步)。
/// </summary>
public DbSet<UserSessionEntity> UserSessions { get; set; }
/// <summary>
/// 安全策略集合(云同步)。
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 审计日志集合(云同步)。
/// </summary>
public DbSet<AuditLogEntity> AuditLogs { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TaskEntity>(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<int>();
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<AttachmentEntity>(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<int>()
.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<UserEntity>(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<UserSessionEntity>(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<SecurityPolicyEntity>(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<AuditLogEntity>(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);
});
}
}
@@ -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;
/// <summary>
/// 附件管理服务实现。
/// </summary>
public class AttachmentService : IAttachmentService
{
private readonly IAttachmentRepository _attachmentRepository;
private readonly ITaskRepository _taskRepository;
private readonly IPlatformAttachmentOpener? _platformOpener;
private readonly ILogger<AttachmentService> _logger;
private const long MaxFileSize = 50 * 1024 * 1024; // 50MB
private const int MaxAttachmentsPerTask = 20;
/// <summary>
/// 获取附件存储根目录(与数据库同级)。
/// </summary>
private static string AttachmentsRootPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Hua.Todo",
"Attachments");
/// <summary>
/// 初始化附件服务。
/// </summary>
/// <param name="attachmentRepository">附件仓储。</param>
/// <param name="taskRepository">任务仓储。</param>
/// <param name="platformOpener">平台文件打开服务(可选,MAUI/Avalonia 注入)。</param>
/// <param name="logger">日志记录器;未注入时使用空日志实现。</param>
public AttachmentService(
IAttachmentRepository attachmentRepository,
ITaskRepository taskRepository,
IPlatformAttachmentOpener? platformOpener = null,
ILogger<AttachmentService>? logger = null)
{
_attachmentRepository = attachmentRepository;
_taskRepository = taskRepository;
_platformOpener = platformOpener;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AttachmentService>.Instance;
}
/// <inheritdoc />
public async Task<AttachmentDto> 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);
}
/// <inheritdoc />
public async Task<AttachmentDto> 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);
}
/// <inheritdoc />
public async Task<List<AttachmentDto>> GetAttachmentsAsync(Guid taskId)
{
var attachments = await _attachmentRepository.GetByTaskIdAsync(taskId);
return attachments.Select(MapToDto).ToList();
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task<OpenAttachmentResponse> 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 };
}
/// <summary>
/// 清理文件名中的危险路径片段,防止路径遍历攻击。
/// </summary>
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;
}
/// <summary>
/// 实体转 DTO 映射。
/// </summary>
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
};
}
}
@@ -1,6 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
namespace Hua.Todo.Application.CloudSync.Auth; namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary> /// <summary>
/// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。 /// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。
@@ -14,7 +14,7 @@ public static class ClaimsPrincipalExtensions
/// <returns>用户 ID。</returns> /// <returns>用户 ID。</returns>
public static Guid? GetUserId(this ClaimsPrincipal user) 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; return Guid.TryParse(value, out var id) ? id : null;
} }
@@ -25,7 +25,7 @@ public static class ClaimsPrincipalExtensions
/// <returns>会话 ID。</returns> /// <returns>会话 ID。</returns>
public static Guid? GetSessionId(this ClaimsPrincipal user) 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; return Guid.TryParse(value, out var id) ? id : null;
} }
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth; namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary> /// <summary>
/// 云同步鉴权使用的 Claim 名称约定。 /// 云同步鉴权使用的 Claim 名称约定。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth; namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary> /// <summary>
/// 云同步权限点常量。 /// 云同步权限点常量。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth; namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary> /// <summary>
/// 默认的角色-权限映射(内置最小集合)。 /// 默认的角色-权限映射(内置最小集合)。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth; namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary> /// <summary>
/// 角色到权限集合的映射器(RBAC 最小落地)。 /// 角色到权限集合的映射器(RBAC 最小落地)。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth; namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary> /// <summary>
/// 云同步会话鉴权默认配置。 /// 云同步会话鉴权默认配置。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary> /// <summary>
/// 用户信息 DTO(管理端使用)。 /// 用户信息 DTO(管理端使用)。
@@ -27,6 +27,14 @@ public class CreateUserRequest
/// </summary> /// </summary>
public class ResetPasswordRequest public class ResetPasswordRequest
{ {
/// <summary>
/// 目标用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 新密码。
/// </summary>
public string NewPassword { get; set; } = string.Empty; public string NewPassword { get; set; } = string.Empty;
} }
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary> /// <summary>
/// 云同步 API 统一错误响应结构。 /// 云同步 API 统一错误响应结构。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary> /// <summary>
/// 登录请求。 /// 登录请求。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary> /// <summary>
/// 服务端探测请求。 /// 服务端探测请求。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary> /// <summary>
/// 安全策略下发 DTO。 /// 安全策略下发 DTO。
@@ -1,7 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary> /// <summary>
/// 云同步任务条目(ABP 标准)。 /// 云同步任务条目(ABP 标准)。
@@ -1,15 +1,15 @@
using Microsoft.EntityFrameworkCore; using Hua.Todo.Application.Repositories;
using Microsoft.AspNetCore.Identity; using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities; 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;
/// <summary> /// <summary>
/// 管理端服务(账户、会话、审计、全局策略管理)。 /// 管理端服务(账户、会话、审计、全局策略管理)。
/// </summary> /// </summary>
public class CloudAdminService public class CloudAdminService : ICloudAdminService
{ {
private readonly TodoDbContext _dbContext; private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher; private readonly IPasswordHasher<UserEntity> _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 }; return new UserDto { Id = user.Id, UserName = user.UserName, Role = user.Role, CreatedAtUtc = user.CreatedAtUtc, UpdatedAtUtc = user.UpdatedAtUtc };
} }
public async Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) public async Task<bool> ResetPasswordAsync(ResetPasswordRequest request, CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.NewPassword))
return false;
return await ResetPasswordAsync(request.UserId, request.NewPassword, cancellationToken);
}
/// <summary>
/// 重置用户密码(内部方法)。
/// </summary>
private async Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
{ {
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return false; if (user == null) return false;
@@ -122,12 +133,13 @@ public class CloudAdminService
#region Audit Logs #region Audit Logs
public async Task<List<AuditLogDto>> GetAuditLogsAsync(int count, CancellationToken cancellationToken) public async Task<List<AuditLogDto>> GetAuditLogsAsync(int? count = 100, CancellationToken cancellationToken = default)
{ {
var take = count ?? 100;
return await _dbContext.AuditLogs return await _dbContext.AuditLogs
.AsNoTracking() .AsNoTracking()
.OrderByDescending(l => l.TimestampUtc) .OrderByDescending(l => l.TimestampUtc)
.Take(count) .Take(take)
.Select(l => new AuditLogDto .Select(l => new AuditLogDto
{ {
Id = l.Id, Id = l.Id,
@@ -144,4 +156,16 @@ public class CloudAdminService
} }
#endregion #endregion
#region ICloudAdminService CancellationToken DynamicApi
Task<List<UserDto>> ICloudAdminService.GetUsersAsync() => GetUsersAsync(CancellationToken.None);
Task<UserDto?> ICloudAdminService.CreateUserAsync(CreateUserRequest request) => CreateUserAsync(request, CancellationToken.None);
Task<bool> ICloudAdminService.ResetPasswordAsync(ResetPasswordRequest request) => ResetPasswordAsync(request, CancellationToken.None);
Task<bool> ICloudAdminService.DeleteUserAsync(Guid userId) => DeleteUserAsync(userId, CancellationToken.None);
Task<List<SessionDto>> ICloudAdminService.GetSessionsAsync() => GetSessionsAsync(CancellationToken.None);
Task<bool> ICloudAdminService.RevokeSessionAsync(Guid sessionId) => RevokeSessionAsync(sessionId, CancellationToken.None);
Task<List<AuditLogDto>> ICloudAdminService.GetAuditLogsAsync(int? count) => GetAuditLogsAsync(count, CancellationToken.None);
#endregion
} }
@@ -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.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; 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;
/// <summary> /// <summary>
/// 云同步认证服务(登录、初始化管理员、二次认证)。 /// 云同步认证服务(登录、初始化管理员、二次认证)。
/// </summary> /// </summary>
public class CloudAuthService public class CloudAuthService : ICloudAuthService
{ {
private readonly TodoDbContext _dbContext; private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher; private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper; private readonly IRolePermissionMapper _rolePermissionMapper;
private readonly ICurrentUserAccessor? _currentUserAccessor;
private static readonly Random _random = new(); private static readonly Random _random = new();
/// <summary> /// <summary>
@@ -23,14 +25,17 @@ public class CloudAuthService
/// <param name="dbContext">数据库上下文。</param> /// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param> /// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param> /// <param name="rolePermissionMapper">角色权限映射器。</param>
/// <param name="currentUserAccessor">HTTP 上下文访问器(可选,用于获取客户端 IP/UA 等)。</param>
public CloudAuthService( public CloudAuthService(
TodoDbContext dbContext, TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher, IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper) IRolePermissionMapper rolePermissionMapper,
ICurrentUserAccessor? currentUserAccessor = null)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_passwordHasher = passwordHasher; _passwordHasher = passwordHasher;
_rolePermissionMapper = rolePermissionMapper; _rolePermissionMapper = rolePermissionMapper;
_currentUserAccessor = currentUserAccessor;
} }
private static string GenerateRandomPassword(int length = 16) private static string GenerateRandomPassword(int length = 16)
@@ -216,7 +221,6 @@ public class CloudAuthService
}; };
} }
/// <summary>
/// <summary> /// <summary>
/// 修改用户密码。 /// 修改用户密码。
/// </summary> /// </summary>
@@ -284,5 +288,57 @@ public class CloudAuthService
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
return true; return true;
} }
#region ICloudAuthService HttpContext
/// <summary>
/// 初始化系统管理员账号(接口方法,从 HttpContext 提取 IP/UA)。
/// </summary>
public async Task<BootstrapAdminResponse?> BootstrapAsync(BootstrapAdminRequest request)
{
var (ip, ua) = GetClientInfo();
return await BootstrapAdminAsync(request.UserName, request.Password, ip, ua, CancellationToken.None);
} }
/// <summary>
/// 用户名密码登录(接口方法,从 HttpContext 提取 IP/UA)。
/// </summary>
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
{
var (ip, ua) = GetClientInfo();
return await LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, CancellationToken.None);
}
/// <summary>
/// 修改当前用户密码(接口方法,从 HttpContext 提取 sessionId + IP/UA)。
/// </summary>
public Task<bool> 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);
}
/// <summary>
/// 登出(接口方法,从 HttpContext 提取 sessionId)。
/// </summary>
public Task<bool> LogoutAsync()
{
var sessionId = _currentUserAccessor?.GetCurrentSessionId();
if (sessionId == null) return Task.FromResult(false);
return LogoutAsync(sessionId.Value, CancellationToken.None);
}
/// <summary>
/// 从 ICurrentUserAccessor 获取客户端 IP 和 User-Agent。
/// </summary>
private (string? ip, string? ua) GetClientInfo()
{
return (_currentUserAccessor?.GetClientIp(), _currentUserAccessor?.GetUserAgent());
}
#endregion
}
@@ -1,32 +1,41 @@
using System.Net; using Hua.Todo.Application.Services.CloudSync.Models;
using System.Net.Http; using Microsoft.Extensions.Logging;
using Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.CloudSync.Services; namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary> /// <summary>
/// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。 /// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。
/// </summary> /// </summary>
public class CloudProbeService public class CloudProbeService : ICloudProbeService
{ {
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CloudProbeService> _logger;
/// <summary> /// <summary>
/// 创建 <see cref="CloudProbeService"/>。 /// 创建 <see cref="CloudProbeService"/>。
/// </summary> /// </summary>
/// <param name="httpClientFactory">HTTP 客户端工厂。</param> /// <param name="httpClientFactory">HTTP 客户端工厂。</param>
public CloudProbeService(IHttpClientFactory httpClientFactory) /// <param name="logger">日志记录器。</param>
public CloudProbeService(IHttpClientFactory httpClientFactory, ILogger<CloudProbeService> logger)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger;
} }
/// <summary> /// <summary>
/// 探测目标 URL 的可达性。 /// 探测目标 URL 的可达性。
/// </summary> /// </summary>
/// <param name="targetUrl">目标 URL。</param> /// <param name="request">探测请求(含 TargetUrl。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>探测响应。</returns> /// <returns>探测响应。</returns>
public async Task<ProbeResponse> ProbeAsync(string targetUrl, CancellationToken cancellationToken) public Task<ProbeResponse> ProbeAsync(ProbeRequest request)
{
return ProbeCoreAsync(request?.TargetUrl ?? string.Empty, CancellationToken.None);
}
/// <summary>
/// 探测目标 URL 的可达性(内部实现)。
/// </summary>
private async Task<ProbeResponse> ProbeCoreAsync(string targetUrl, CancellationToken cancellationToken)
{ {
var response = new ProbeResponse(); var response = new ProbeResponse();
@@ -91,7 +100,6 @@ public class CloudProbeService
} }
else else
{ {
// 非 2xx 也说明服务器可达,仅作为提示
if (!response.IsHttps) if (!response.IsHttps)
{ {
response.Type = "warn"; response.Type = "warn";
@@ -112,6 +120,7 @@ public class CloudProbeService
response.Type = response.IsHttps ? "error" : "warn"; response.Type = response.IsHttps ? "error" : "warn";
response.Title = "存在风险"; response.Title = "存在风险";
response.Description = "探测超时(网络不可达或目标无响应)。"; response.Description = "探测超时(网络不可达或目标无响应)。";
_logger.LogWarning("探测超时: {TargetUrl}", targetUrl);
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
@@ -121,15 +130,16 @@ public class CloudProbeService
response.Description = response.IsHttps response.Description = response.IsHttps
? $"探测失败:{ex.Message}" ? $"探测失败:{ex.Message}"
: $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。"; : $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。";
_logger.LogWarning(ex, "探测请求失败: {TargetUrl}", targetUrl);
} }
catch (Exception) catch (Exception ex)
{ {
response.IsReachable = false; response.IsReachable = false;
response.Type = "error"; response.Type = "error";
response.Description = "探测过程中发生未知错误。"; response.Description = "探测过程中发生未知错误。";
_logger.LogError(ex, "探测未知错误: {TargetUrl}", targetUrl);
} }
return response; return response;
} }
} }
@@ -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;
/// <summary>
/// 云同步代理服务(单例)。
/// 职责:管理代理 URL 状态 + 云同步请求转发到远程 Host + DI/管道配置。
/// </summary>
public class CloudSyncProxyService
{
private readonly IHttpClientFactory _httpClientFactory;
private string? _cloudSyncUrl;
private readonly object _lock = new();
/// <summary>
/// 需要代理的云同步路径根。匹配时要求路径等于根路径或以“根路径/”开头,
/// 避免 /api/tasks 无尾斜杠漏代理,也避免误匹配 /api/task 等本地 Todo API。
/// </summary>
private static readonly string[] ProxyPathRoots = { "/api/auth", "/api/tasks", "/api/sync", "/api/security", "/api/cloud-sync" };
public CloudSyncProxyService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// 获取或设置云同步服务端地址。
/// </summary>
public string? CloudSyncUrl
{
get { lock (_lock) return _cloudSyncUrl; }
set { lock (_lock) _cloudSyncUrl = string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); }
}
/// <summary>
/// 判断请求路径是否需要代理。
/// </summary>
public bool ShouldProxy(string requestPath)
{
return ProxyPathRoots.Any(root =>
requestPath.Equals(root, StringComparison.OrdinalIgnoreCase)
|| requestPath.StartsWith($"{root}/", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 将当前请求代理转发到远程 Host,并回写响应。
/// </summary>
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);
}
/// <summary>
/// 设置持久化文件路径(由宿主在创建后调用,可选)。
/// </summary>
public string? PersistencePath { get; set; }
}
/// <summary>
/// CloudSyncProxyService 的 DI/管道扩展方法。
/// </summary>
public static class CloudSyncProxyServiceExtensions
{
/// <summary>
/// 注册云同步反向代理所需的 DI 服务(供嵌入式 MAUI/Avalonia WebServer 使用)。
/// </summary>
public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services)
{
services.AddSingleton<CloudSyncProxyService>();
services.AddScoped<ICloudSyncProxySettingsService, CloudSyncProxySettingsService>();
services.AddHttpClient("CloudSyncProxy", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
return services;
}
/// <summary>
/// 注册云同步反向代理管道中间件。
/// </summary>
public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder)
{
return builder.Use(async (context, next) =>
{
var proxyService = context.RequestServices.GetRequiredService<CloudSyncProxyService>();
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);
});
}
}
@@ -0,0 +1,93 @@
using System.Text.Json;
using Hua.Todo.Application.Services.CloudSync.Services;
namespace Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
/// <summary>
/// 云同步代理设置服务(Scoped)。
/// 通过动态 API 服务接口自动暴露为 /api/cloudSyncProxySettingsGET/POST)。
/// 依赖 <see cref="CloudSyncProxyService"/> 管理 URL 状态,支持持久化到 appsettings.json。
/// </summary>
public class CloudSyncProxySettingsService : ICloudSyncProxySettingsService
{
private readonly CloudSyncProxyService _proxyService;
public CloudSyncProxySettingsService(CloudSyncProxyService proxyService)
{
_proxyService = proxyService;
}
/// <inheritdoc />
public Task<ProxySettingsDto> GetAsync()
{
return Task.FromResult(new ProxySettingsDto
{
ServerUrl = _proxyService.CloudSyncUrl ?? string.Empty
});
}
/// <inheritdoc />
public Task<ProxySettingsDto> SetAsync(ProxySettingsDto dto)
{
var serverUrl = dto?.ServerUrl ?? string.Empty;
_proxyService.CloudSyncUrl = serverUrl;
PersistToAppSettings();
return Task.FromResult(new ProxySettingsDto
{
ServerUrl = _proxyService.CloudSyncUrl ?? string.Empty
});
}
/// <summary>
/// 将当前 URL 持久化到 appsettings.json 的 WebServer.CloudSyncUrl 字段。
/// </summary>
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
{
// 保存失败不影响前端
}
}
}
@@ -1,26 +1,56 @@
using Microsoft.EntityFrameworkCore; using Hua.Todo.Application.Repositories;
using Microsoft.EntityFrameworkCore.Storage; using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Models; using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities; 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;
/// <summary> /// <summary>
/// 云同步任务服务(按用户隔离)。 /// 云同步任务服务(按用户隔离)。
/// 实现 ABP 风格审计字段和 LWW 冲突解决策略。 /// 实现 ABP 风格审计字段和 LWW 冲突解决策略。
/// </summary> /// </summary>
public class CloudTaskSyncService public class CloudTaskSyncService : ICloudTaskSyncService
{ {
private readonly TodoDbContext _dbContext; private readonly TodoDbContext _dbContext;
private readonly ILogger<CloudTaskSyncService> _logger;
private readonly ICurrentUserAccessor? _currentUserAccessor;
/// <summary> /// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。 /// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary> /// </summary>
/// <param name="dbContext">数据库上下文。</param> /// <param name="dbContext">数据库上下文。</param>
public CloudTaskSyncService(TodoDbContext dbContext) /// <param name="logger">日志记录器。</param>
/// <param name="currentUserAccessor">HTTP 上下文访问器(可选,用于从 HttpContext 获取当前用户 ID)。</param>
public CloudTaskSyncService(
TodoDbContext dbContext,
ILogger<CloudTaskSyncService> logger,
ICurrentUserAccessor? currentUserAccessor = null)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger;
_currentUserAccessor = currentUserAccessor;
}
/// <summary>
/// 获取当前用户的任务全量(含 Tombstone)。
/// 从 HttpContext 获取用户 ID。
/// </summary>
public Task<List<CloudTaskItem>> GetTasksAsync()
{
var userId = GetCurrentUserId();
return GetTasksAsync(userId, CancellationToken.None);
}
/// <summary>
/// 执行同步(增改删),并返回最新全量。
/// 从 HttpContext 获取用户 ID。
/// </summary>
public Task<SyncResponse> SyncAsync(SyncRequest request)
{
var userId = GetCurrentUserId();
return SyncAsync(userId, request, CancellationToken.None);
} }
/// <summary> /// <summary>
@@ -31,7 +61,6 @@ public class CloudTaskSyncService
/// <returns>任务列表(含已删除任务)。</returns> /// <returns>任务列表(含已删除任务)。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken) public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{ {
// 使用 IgnoreQueryFilters 获取包括已删除任务的全量
var tasks = await _dbContext.Tasks var tasks = await _dbContext.Tasks
.AsNoTracking() .AsNoTracking()
.IgnoreQueryFilters() .IgnoreQueryFilters()
@@ -65,16 +94,12 @@ public class CloudTaskSyncService
await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken); await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken);
} }
// 保存删除操作
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
} }
// 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理) // 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理)
if (request.Upserts.Count > 0) if (request.Upserts.Count > 0)
{ {
// 去重策略:
// - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送)
// - 全新任务(Id == null):全部保留,不参与去重
var dedupedById = request.Upserts var dedupedById = request.Upserts
.Where(u => u.Id.HasValue) .Where(u => u.Id.HasValue)
.GroupBy(u => u.Id!.Value) .GroupBy(u => u.Id!.Value)
@@ -86,13 +111,9 @@ public class CloudTaskSyncService
var uniqueUpserts = dedupedById var uniqueUpserts = dedupedById
.Concat(newTasks) .Concat(newTasks)
.Where(u => !string.IsNullOrWhiteSpace(u.Title)) .Where(u => !string.IsNullOrWhiteSpace(u.Title))
.OrderByDescending(u => u.LastModificationTime) // 较新的先处理 .OrderByDescending(u => u.LastModificationTime)
.ToList(); .ToList();
// 使用事务包裹,确保读取与写入的一致性
// 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突
// ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景;
// 对于极端并发写入冲突,SaveChangesAsync 处有重试保护
const int maxSaveRetries = 3; const int maxSaveRetries = 3;
for (int retry = 0; ; retry++) for (int retry = 0; ; retry++)
@@ -101,31 +122,25 @@ public class CloudTaskSyncService
try try
{ {
// 在事务内查询已有任务,确保读取到最新已提交数据
var existingTasks = await _dbContext.Tasks var existingTasks = await _dbContext.Tasks
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(t => t.UserId == userId) .Where(t => t.UserId == userId)
.ToDictionaryAsync(t => t.Id, cancellationToken); .ToDictionaryAsync(t => t.Id, cancellationToken);
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射)
var clientIdToEntity = new Dictionary<Guid, TaskEntity>(); var clientIdToEntity = new Dictionary<Guid, TaskEntity>();
// 第一遍:处理根任务(ParentTaskId == null
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue)) foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
{ {
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken); await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
} }
// 保存根任务,获取服务器分配的 ID
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
// 将新创建的根任务加入 existingTasks,供子任务反查
foreach (var kvp in clientIdToEntity) foreach (var kvp in clientIdToEntity)
{ {
existingTasks[kvp.Value.Id] = kvp.Value; existingTasks[kvp.Value.Id] = kvp.Value;
} }
// 第二遍:处理子任务(ParentTaskId != null
foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue)) foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue))
{ {
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken); await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
@@ -134,27 +149,26 @@ public class CloudTaskSyncService
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
break; // 成功,退出重试循环 break;
} }
catch (DbUpdateException ex) when (retry < maxSaveRetries - 1 catch (DbUpdateException ex) when (retry < maxSaveRetries - 1
&& ex.InnerException is Microsoft.Data.Sqlite.SqliteException se && ex.InnerException is Microsoft.Data.Sqlite.SqliteException se
&& se.SqliteErrorCode == 19 && se.SqliteErrorCode == 19
&& se.Message.Contains("UNIQUE constraint")) && se.Message.Contains("UNIQUE constraint"))
{ {
// 并发写入冲突:其他事务已插入相同 Id 的任务 _logger.LogWarning(ex, "同步 SaveChanges 唯一约束冲突(重试 {Retry}/{MaxRetries}: {UserId}", retry + 1, maxSaveRetries, userId);
// 回滚当前事务,下次循环将重新查询并正确识别已存在实体
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
// 清理 ChangeTracker,避免下次循环携带已回滚的实体状态
foreach (var entry in _dbContext.ChangeTracker.Entries<TaskEntity>().ToList()) foreach (var entry in _dbContext.ChangeTracker.Entries<TaskEntity>().ToList())
{ {
entry.State = EntityState.Detached; entry.State = EntityState.Detached;
} }
continue; // 重试 continue;
} }
catch catch (Exception ex)
{ {
_logger.LogError(ex, "同步 SaveChanges 失败(非可重试异常): {UserId}", userId);
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
throw; throw;
} }
@@ -168,9 +182,6 @@ public class CloudTaskSyncService
}; };
} }
/// <summary>
/// 递归软删除任务及其子任务。
/// </summary>
private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken) private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken)
{ {
var task = await _dbContext.Tasks var task = await _dbContext.Tasks
@@ -183,10 +194,8 @@ public class CloudTaskSyncService
task.DeletionTime = DateTime.UtcNow; task.DeletionTime = DateTime.UtcNow;
task.DeleterId = userId; task.DeleterId = userId;
// 显式标记为已修改
_dbContext.Entry(task).State = EntityState.Modified; _dbContext.Entry(task).State = EntityState.Modified;
// 递归删除子任务
var childTasks = await _dbContext.Tasks var childTasks = await _dbContext.Tasks
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(t => t.ParentTaskId == taskId) .Where(t => t.ParentTaskId == taskId)
@@ -199,9 +208,6 @@ public class CloudTaskSyncService
} }
} }
/// <summary>
/// 处理单个 upsert 请求(创建或更新)。
/// </summary>
private async Task ProcessUpsertAsync( private async Task ProcessUpsertAsync(
Guid userId, Guid userId,
CloudTaskUpsert upsert, CloudTaskUpsert upsert,
@@ -209,7 +215,6 @@ public class CloudTaskSyncService
Dictionary<Guid, TaskEntity> clientIdToEntity, Dictionary<Guid, TaskEntity> clientIdToEntity,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID
Guid? parentId = upsert.ParentTaskId; Guid? parentId = upsert.ParentTaskId;
if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity)) if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity))
{ {
@@ -219,14 +224,11 @@ public class CloudTaskSyncService
TaskEntity? entity = null; TaskEntity? entity = null;
bool shouldAdd = false; bool shouldAdd = false;
// 尝试从已缓存的字典中查找
TaskEntity? existingEntity = null; TaskEntity? existingEntity = null;
bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity); bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity);
if (!foundInCache && upsert.Id.HasValue) if (!foundInCache && upsert.Id.HasValue)
{ {
// 字典未命中,但客户端提供了 Id —— 可能是跨请求/跨 UserId 的遗留数据
// 直接查询 DB 以确保不会重复插入
existingEntity = await _dbContext.Tasks existingEntity = await _dbContext.Tasks
.IgnoreQueryFilters() .IgnoreQueryFilters()
.AsNoTracking() .AsNoTracking()
@@ -235,12 +237,10 @@ public class CloudTaskSyncService
if (existingEntity != null) if (existingEntity != null)
{ {
// 实体已存在(字典或 DB 中),进行 LWW 冲突判断
if (upsert.LastModificationTime.HasValue && if (upsert.LastModificationTime.HasValue &&
(!existingEntity.LastModificationTime.HasValue || (!existingEntity.LastModificationTime.HasValue ||
upsert.LastModificationTime >= existingEntity.LastModificationTime)) upsert.LastModificationTime >= existingEntity.LastModificationTime))
{ {
// 客户端版本更新,接受更新
entity = existingEntity; entity = existingEntity;
entity.Title = (upsert.Title ?? string.Empty).Trim(); entity.Title = (upsert.Title ?? string.Empty).Trim();
entity.Priority = upsert.Priority; entity.Priority = upsert.Priority;
@@ -250,7 +250,6 @@ public class CloudTaskSyncService
entity.LastModificationTime = upsert.LastModificationTime; entity.LastModificationTime = upsert.LastModificationTime;
entity.LastModifierId = userId; entity.LastModifierId = userId;
// 若实体来自 AsNoTracking 查询(非 tracked),需显式附加并标记为 Modified
var entry = _dbContext.Entry(entity); var entry = _dbContext.Entry(entity);
if (entry.State == EntityState.Detached) if (entry.State == EntityState.Detached)
{ {
@@ -262,11 +261,9 @@ public class CloudTaskSyncService
entry.State = EntityState.Modified; entry.State = EntityState.Modified;
} }
} }
// 否则保持服务端版本不变,不做任何操作
} }
else else
{ {
// 确认不存在,新建任务
entity = new TaskEntity entity = new TaskEntity
{ {
Id = upsert.Id ?? Guid.NewGuid(), Id = upsert.Id ?? Guid.NewGuid(),
@@ -286,17 +283,14 @@ public class CloudTaskSyncService
if (shouldAdd && entity != null) if (shouldAdd && entity != null)
{ {
// 防御性检查:确保实体 Id 未被 ChangeTracker 跟踪,防止 UNIQUE 约束冲突
var trackedEntry = _dbContext.ChangeTracker.Entries<TaskEntity>() var trackedEntry = _dbContext.ChangeTracker.Entries<TaskEntity>()
.FirstOrDefault(e => e.Entity.Id == entity.Id); .FirstOrDefault(e => e.Entity.Id == entity.Id);
if (trackedEntry != null) if (trackedEntry != null)
{ {
// 实体已被跟踪(可能来自并发操作或其他路径),使用已跟踪的实体
entity = trackedEntry.Entity; entity = trackedEntry.Entity;
if (trackedEntry.State != EntityState.Added) if (trackedEntry.State != EntityState.Added)
{ {
// 仅更新非新增实体的字段
entity.Title = (upsert.Title ?? string.Empty).Trim(); entity.Title = (upsert.Title ?? string.Empty).Trim();
entity.Priority = upsert.Priority; entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted; entity.IsCompleted = upsert.IsCompleted;
@@ -312,7 +306,6 @@ public class CloudTaskSyncService
_dbContext.Tasks.Add(entity); _dbContext.Tasks.Add(entity);
} }
// 立即更新 existingTasks,避免同一批次内重复插入相同 Id
existingTasks[entity.Id] = entity; existingTasks[entity.Id] = entity;
if (upsert.Id.HasValue) if (upsert.Id.HasValue)
@@ -322,9 +315,6 @@ public class CloudTaskSyncService
} }
} }
/// <summary>
/// 将实体映射到 DTO。
/// </summary>
private static CloudTaskItem MapToItem(TaskEntity task) private static CloudTaskItem MapToItem(TaskEntity task)
{ {
return new CloudTaskItem return new CloudTaskItem
@@ -344,4 +334,13 @@ public class CloudTaskSyncService
DeleterId = task.DeleterId DeleterId = task.DeleterId
}; };
} }
/// <summary>
/// 从 HttpContext 获取当前用户 ID。
/// </summary>
private Guid GetCurrentUserId()
{
return _currentUserAccessor?.GetCurrentUserId()
?? throw new UnauthorizedAccessException("未登录或会话已过期");
}
} }
@@ -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;
/// <summary>
/// 管理端服务接口(账户、会话、审计日志管理)。
/// 继承 <see cref="IDynamicApiService"/> 自动暴露为 /api/admin/* 端点。
/// 所有方法均需 users:manage 权限。
/// 不暴露为 MCP 工具(需认证)。
/// </summary>
[DynamicApiRoute("admin")]
[RequirePermission(CloudPermissions.UsersManage)]
public interface ICloudAdminService : IDynamicApiService
{
/// <summary>
/// 获取所有云用户列表。
/// GET /api/admin/users
/// </summary>
[HttpGet("users")]
Task<List<UserDto>> GetUsersAsync();
/// <summary>
/// 创建新用户。
/// POST /api/admin/users
/// </summary>
[HttpPost("users")]
Task<UserDto?> CreateUserAsync(CreateUserRequest request);
/// <summary>
/// 重置指定用户的密码。
/// POST /api/admin/resetPassword
/// </summary>
[HttpPost("resetPassword")]
Task<bool> ResetPasswordAsync(ResetPasswordRequest request);
/// <summary>
/// 删除指定用户。
/// DELETE /api/admin/users/{userId}
/// </summary>
[HttpDelete("users")]
Task<bool> DeleteUserAsync(Guid userId);
/// <summary>
/// 获取所有活跃会话。
/// GET /api/admin/sessions
/// </summary>
[HttpGet("sessions")]
Task<List<SessionDto>> GetSessionsAsync();
/// <summary>
/// 吊销指定会话。
/// DELETE /api/admin/sessions/{sessionId}
/// </summary>
[HttpDelete("sessions")]
Task<bool> RevokeSessionAsync(Guid sessionId);
/// <summary>
/// 获取最近 N 条审计日志。
/// GET /api/admin/auditLogs
/// </summary>
[HttpGet("auditLogs")]
Task<List<AuditLogDto>> GetAuditLogsAsync(int? count = 100);
}
@@ -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;
/// <summary>
/// 云同步认证服务接口。
/// 继承 <see cref="IDynamicApiService"/> 自动暴露为 /api/auth/* 端点。
/// 不暴露为 MCP 工具(需认证)。
/// </summary>
[DynamicApiRoute("auth")]
public interface ICloudAuthService : IDynamicApiService
{
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// POST /api/auth/bootstrap
/// </summary>
[HttpPost("bootstrap")]
[AllowAnonymous]
Task<BootstrapAdminResponse?> BootstrapAsync(BootstrapAdminRequest request);
/// <summary>
/// 用户名密码登录并创建会话。
/// POST /api/auth/login
/// </summary>
[HttpPost("login")]
[AllowAnonymous]
Task<LoginResponse?> LoginAsync(LoginRequest request);
/// <summary>
/// 登出:移除当前会话。
/// POST /api/auth/logout
/// </summary>
[HttpPost("logout")]
Task<bool> LogoutAsync();
/// <summary>
/// 修改当前用户密码。
/// POST /api/auth/change-password
/// </summary>
[HttpPost("change-password")]
Task<bool> ChangePasswordAsync(ChangePasswordRequest request);
}
@@ -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;
/// <summary>
/// 服务端探测服务接口。
/// 继承 <see cref="IDynamicApiService"/> 自动暴露为 /api/cloud-sync/* 端点。
/// 允许匿名访问,用于客户端探测服务端可达性。
/// 不暴露为 MCP 工具(无认证需求但内网探测能力不宜通过 MCP 暴露)。
/// </summary>
[DynamicApiRoute("cloud-sync")]
public interface ICloudProbeService : IDynamicApiService
{
/// <summary>
/// 探测目标 URL 的可达性。
/// POST /api/cloud-sync/probe
/// </summary>
[AllowAnonymous]
[HttpPost("probe")]
Task<ProbeResponse> ProbeAsync(ProbeRequest request);
}
@@ -0,0 +1,38 @@
using System.ComponentModel;
using Hua.Todo.HttpApi.Interfaces;
namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary>
/// 云同步代理设置服务接口。
/// 继承 <see cref="IDynamicApiService"/> 自动暴露为 HTTP API。
/// 不暴露为 MCP 工具(CloudSync 命名空间下的接口统一排除)。
/// </summary>
public interface ICloudSyncProxySettingsService : IDynamicApiService
{
/// <summary>
/// 获取当前云同步服务器 URL。
/// 路由:GET /api/cloudSyncProxySettings
/// </summary>
[Description("获取云同步服务端地址")]
Task<ProxySettingsDto> GetAsync();
/// <summary>
/// 设置云同步服务器 URL 并持久化。
/// 路由:POST /api/cloudSyncProxySettings
/// </summary>
/// <param name="dto">包含 serverUrl 的 DTO。</param>
[Description("设置云同步服务端地址")]
Task<ProxySettingsDto> SetAsync(ProxySettingsDto dto);
}
/// <summary>
/// 代理设置 DTO。
/// </summary>
public class ProxySettingsDto
{
/// <summary>
/// 云同步服务器 URL。
/// </summary>
public string ServerUrl { get; set; } = string.Empty;
}
@@ -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;
/// <summary>
/// 云同步任务服务接口。
/// 继承 <see cref="IDynamicApiService"/> 自动暴露为 /api/tasks/* 端点。
/// 不暴露为 MCP 工具(需认证)。
/// </summary>
[DynamicApiRoute("tasks")]
public interface ICloudTaskSyncService : IDynamicApiService
{
/// <summary>
/// 获取当前用户的任务全量(含逻辑删除的 Tombstone)。
/// GET /api/tasks
/// </summary>
[RequirePermission(CloudPermissions.TasksRead)]
[HttpGet("")]
Task<List<CloudTaskItem>> GetTasksAsync();
/// <summary>
/// 执行同步(增改删),返回最新全量。
/// POST /api/tasks
/// </summary>
[RequirePermission(CloudPermissions.SyncWrite)]
[HttpPost("")]
Task<SyncResponse> SyncAsync(SyncRequest request);
}
@@ -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;
/// <summary>
/// 安全策略服务接口。
/// 继承 <see cref="IDynamicApiService"/> 自动暴露为 /api/security/* 端点。
/// 不暴露为 MCP 工具(需认证)。
/// </summary>
[DynamicApiRoute("security")]
public interface ISecurityPolicyService : IDynamicApiService
{
/// <summary>
/// 获取当前用户的安全策略。
/// GET /api/security/policy
/// </summary>
[RequirePermission(CloudPermissions.PolicyRead)]
[HttpGet("policy")]
Task<SecurityPolicyDto> GetPolicyAsync();
/// <summary>
/// 更新当前用户的安全策略。
/// PUT /api/security/policy
/// </summary>
[RequirePermission(CloudPermissions.PolicyWrite)]
[HttpPut("policy")]
Task<SecurityPolicyDto> UpdatePolicyAsync(UpdateSecurityPolicyRequest request);
}
@@ -1,24 +1,46 @@
using Microsoft.EntityFrameworkCore; using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.CloudSync.Models; using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace Hua.Todo.Application.CloudSync.Services; namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary> /// <summary>
/// 安全策略服务(按用户隔离)。 /// 安全策略服务(按用户隔离)。
/// </summary> /// </summary>
public class SecurityPolicyService public class SecurityPolicyService : ISecurityPolicyService
{ {
private readonly TodoDbContext _dbContext; private readonly TodoDbContext _dbContext;
private readonly ICurrentUserAccessor? _currentUserAccessor;
/// <summary> /// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。 /// 创建 <see cref="SecurityPolicyService"/>。
/// </summary> /// </summary>
/// <param name="dbContext">数据库上下文。</param> /// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext) /// <param name="currentUserAccessor">HTTP 上下文访问器(可选,用于从 HttpContext 获取当前用户 ID)。</param>
public SecurityPolicyService(TodoDbContext dbContext, ICurrentUserAccessor? currentUserAccessor = null)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_currentUserAccessor = currentUserAccessor;
}
/// <summary>
/// 获取当前用户的安全策略(从 HttpContext 获取用户 ID)。
/// </summary>
public Task<SecurityPolicyDto> GetPolicyAsync()
{
var userId = GetCurrentUserId();
return GetPolicyAsync(userId, CancellationToken.None);
}
/// <summary>
/// 更新当前用户的安全策略(从 HttpContext 获取用户 ID)。
/// </summary>
public Task<SecurityPolicyDto> UpdatePolicyAsync(UpdateSecurityPolicyRequest request)
{
var userId = GetCurrentUserId();
return UpdatePolicyAsync(userId, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, CancellationToken.None);
} }
/// <summary> /// <summary>
@@ -76,4 +98,14 @@ public class SecurityPolicyService
return await GetPolicyAsync(userId, cancellationToken); return await GetPolicyAsync(userId, cancellationToken);
} }
/// <summary>
/// 从 HttpContext 获取当前用户 ID。
/// </summary>
/// <exception cref="UnauthorizedAccessException">未登录时抛出。</exception>
private Guid GetCurrentUserId()
{
return _currentUserAccessor?.GetCurrentUserId()
?? throw new UnauthorizedAccessException("未登录或会话已过期");
}
} }
@@ -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;
/// <summary>
/// 附件管理服务接口,继承 <see cref="IDynamicApiService"/> 自动暴露为 HTTP API 和 MCP 工具。
/// </summary>
[RemoteService(IsEnabled = true)]
public interface IAttachmentService : IDynamicApiService
{
/// <summary>
/// 上传文件附件。
/// POST /api/attachment/upload
/// </summary>
/// <param name="request">上传请求,包含 Base64 编码的文件内容。</param>
/// <returns>上传后的附件 DTO。</returns>
[System.ComponentModel.Description("上传文件附件")]
[HttpPost("upload")]
Task<AttachmentDto> UploadAsync(UploadAttachmentRequest request);
/// <summary>
/// 添加外部链接附件。
/// POST /api/attachment/addLink
/// </summary>
/// <param name="request">添加链接请求,包含 URL 和可选名称。</param>
/// <returns>创建的附件 DTO。</returns>
[System.ComponentModel.Description("添加外部链接附件")]
[HttpPost("addLink")]
Task<AttachmentDto> AddLinkAsync(AddLinkRequest request);
/// <summary>
/// 获取待办项的附件列表。
/// GET /api/attachment/list
/// </summary>
/// <param name="taskId">待办项 ID。</param>
/// <returns>附件 DTO 列表。</returns>
[System.ComponentModel.Description("获取待办项的附件列表")]
[HttpGet]
Task<List<AttachmentDto>> GetAttachmentsAsync(Guid taskId);
/// <summary>
/// 删除附件。
/// DELETE /api/attachment/delete
/// </summary>
/// <param name="id">附件 ID。</param>
[System.ComponentModel.Description("删除附件")]
[HttpDelete]
Task DeleteAsync(Guid id);
/// <summary>
/// 打开附件(调用系统关联程序打开文件或用浏览器打开 URL)。
/// POST /api/attachment/open
/// </summary>
/// <param name="id">附件 ID。</param>
/// <returns>打开结果。</returns>
[System.ComponentModel.Description("打开附件")]
[HttpPost("open")]
Task<OpenAttachmentResponse> OpenAsync(Guid id);
}
@@ -0,0 +1,34 @@
namespace Hua.Todo.Application.Services.Interfaces;
/// <summary>
/// 当前用户访问器抽象。
/// 隔离 I/O 层(HttpContext)对业务层的直接依赖。
/// 由宿主层(HttpApi.AspNetCore / MAUI / Avalonia)注入对应平台实现。
/// 对于无用户上下文的场景(如匿名请求),所有方法返回 null。
/// </summary>
public interface ICurrentUserAccessor
{
/// <summary>
/// 获取当前用户 ID。
/// 若当前无用户上下文则返回 null。
/// </summary>
Guid? GetCurrentUserId();
/// <summary>
/// 获取当前会话 IDBearer Token 对应的 SessionId)。
/// 若当前无会话上下文则返回 null。
/// </summary>
Guid? GetCurrentSessionId();
/// <summary>
/// 获取客户端 IP 地址。
/// 若无法获取则返回 null。
/// </summary>
string? GetClientIp();
/// <summary>
/// 获取客户端 User-Agent。
/// 若无法获取则返回 null。
/// </summary>
string? GetUserAgent();
}
@@ -1,6 +1,7 @@
using Hua.Todo.Application.Models; using Hua.Todo.Application.Models;
using Hua.Todo.HttpApi.Interfaces;
namespace Hua.Todo.Application.Interfaces; namespace Hua.Todo.Application.Services.Interfaces;
/// <summary> /// <summary>
/// 任务管理服务接口 /// 任务管理服务接口
@@ -18,7 +19,7 @@ public interface ITaskService : IDynamicApiService
/// </summary> /// </summary>
/// <param name="id">任务唯一标识符</param> /// <param name="id">任务唯一标识符</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns> /// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(int id); Task<TaskDto?> GetTaskByIdAsync(Guid id);
/// <summary> /// <summary>
/// 获取未完成的任务 /// 获取未完成的任务
@@ -51,18 +52,18 @@ public interface ITaskService : IDynamicApiService
/// </summary> /// </summary>
/// <param name="id">任务唯一标识符</param> /// <param name="id">任务唯一标识符</param>
/// <returns>更新状态后的任务 DTO</returns> /// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(int id); Task<TaskDto> ToggleCompleteAsync(Guid id);
/// <summary> /// <summary>
/// 删除任务 /// 删除任务
/// </summary> /// </summary>
/// <param name="id">要删除的任务唯一标识符</param> /// <param name="id">要删除的任务唯一标识符</param>
Task DeleteTaskAsync(int id); Task DeleteTaskAsync(Guid id);
/// <summary> /// <summary>
/// 获取子任务列表 /// 获取子任务列表
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务唯一标识符</param> /// <param name="parentTaskId">父任务唯一标识符</param>
/// <returns>该父任务下的所有子任务列表</returns> /// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId); Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId);
} }
@@ -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;
/// <summary>
/// 会议业务服务接口,继承 <see cref="IDynamicApiService"/> 自动暴露为 HTTP API 和 MCP 工具。
/// </summary>
[RemoteService(IsEnabled = true)]
public interface IMeetingService : IDynamicApiService
{
/// <summary>
/// 保存/更新会议纪要,仅限 Meeting 类型的待办项。
/// POST /api/meeting/saveNotes
/// </summary>
/// <param name="request">保存纪要请求,包含 taskId 和纪要内容。</param>
/// <returns>保存结果。</returns>
[System.ComponentModel.Description("保存/更新会议纪要")]
[HttpPost("saveNotes")]
Task<SaveNotesResponse> SaveNotesAsync(SaveNotesRequest request);
/// <summary>
/// 获取会议详情(含纪要、录音时长等)。
/// GET /api/meeting/{taskId}/getMeetingDetail
/// </summary>
/// <param name="taskId">待办项 ID。</param>
/// <returns>会议详情。</returns>
[System.ComponentModel.Description("获取会议详情")]
[HttpGet]
Task<MeetingDetailResponse> GetMeetingDetailAsync(Guid taskId);
/// <summary>
/// 上传录音文件进行语音转写,结果自动保存到会议纪要。
/// POST /api/meeting/transcribeAudio
/// </summary>
/// <param name="request">转写请求,包含 taskId、Base64 音频数据和格式。</param>
/// <returns>转写结果,包含转写文字和音频时长。</returns>
[System.ComponentModel.Description("上传录音文件进行语音转写")]
[HttpPost("transcribeAudio")]
Task<TranscribeResponse> TranscribeAudioAsync(TranscribeRequest request);
/// <summary>
/// AI 拆分会议为待办项建议。
/// POST /api/meeting/breakdown
/// </summary>
/// <param name="request">拆分请求,包含 taskId 和可选会议纪要文字。</param>
/// <returns>拆分建议列表。</returns>
[System.ComponentModel.Description("AI 拆分会议为待办项建议")]
[HttpPost("breakdown")]
Task<BreakdownResponse> RequestBreakdownAsync(BreakdownRequest request);
/// <summary>
/// 确认 AI 拆分建议并批量创建子任务。
/// POST /api/meeting/confirmBreakdown
/// </summary>
/// <param name="request">确认请求,包含 taskId 和选定的子任务列表。</param>
/// <returns>批量创建结果。</returns>
[System.ComponentModel.Description("确认 AI 拆分建议并批量创建子任务")]
[HttpPost("confirmBreakdown")]
Task<BatchCreateResult> ConfirmBreakdownAsync(ConfirmBreakdownRequest request);
}
@@ -0,0 +1,17 @@
namespace Hua.Todo.Application.Services.Meeting;
/// <summary>
/// 语音转写服务接口。
/// 将音频文件流转写为文字,供会议录音转写使用。
/// </summary>
public interface ISttService
{
/// <summary>
/// 将音频文件转写为文字。
/// </summary>
/// <param name="audioStream">音频文件流。</param>
/// <param name="format">音频格式,如 "webm"、"mp4"、"wav"。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>转写后的文字内容。</returns>
Task<string> TranscribeAsync(Stream audioStream, string format, CancellationToken ct = default);
}
@@ -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;
/// <summary>
/// 会议 AI 拆分服务,将会议文字内容分析为待办项建议。
/// 复用工单 02 的 <see cref="ILlmClientService"/> 进行 LLM 调用。
/// </summary>
public class MeetingAiBreakdownService
{
private readonly ILlmClientService _llmClient;
private readonly ITaskRepository _taskRepo;
private readonly ITaskService _taskService;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// 最大可提交给 LLM 的会议文本长度,超长则截断。
/// </summary>
private const int MaxMeetingTextLength = 4000;
/// <summary>
/// 初始化 <see cref="MeetingAiBreakdownService"/> 实例。
/// </summary>
/// <param name="llmClient">LLM 客户端,用于调用 AI 分析。</param>
/// <param name="taskRepo">任务仓储,用于查找与验证。</param>
/// <param name="taskService">任务服务,用于批量创建子任务。</param>
public MeetingAiBreakdownService(
ILlmClientService llmClient,
ITaskRepository taskRepo,
ITaskService taskService)
{
_llmClient = llmClient;
_taskRepo = taskRepo;
_taskService = taskService;
}
/// <summary>
/// 分析会议内容,调用 LLM 返回待办项建议列表。
/// </summary>
/// <param name="taskId">会议待办项 ID。</param>
/// <param name="notes">会议纪要文字(可选,不传则使用已保存的 meetingNotes)。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>待办项建议列表。</returns>
/// <exception cref="KeyNotFoundException">当 taskId 不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当待办项不是会议类型或内容为空时抛出。</exception>
/// <exception cref="HttpRequestException">当 LLM API 调用失败时抛出。</exception>
public async Task<List<MeetingTaskSuggestion>> 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);
}
/// <summary>
/// 确认 AI 拆分建议并批量创建子任务。
/// </summary>
/// <param name="taskId">会议待办项 ID。</param>
/// <param name="subTasks">要创建的子任务列表。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>批量创建结果。</returns>
/// <exception cref="KeyNotFoundException">当 taskId 不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当待办项不是会议类型或子任务列表为空时抛出。</exception>
public async Task<BatchCreateResult> ConfirmAndCreateAsync(
Guid taskId, List<SubTaskCreateItem> 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<TaskDto>();
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 };
}
/// <summary>
/// 构造会议拆分专用的 LLM prompt。
/// </summary>
/// <param name="meetingText">会议纪要文字。</param>
/// <param name="title">会议标题。</param>
/// <returns>完整的 prompt 文本。</returns>
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\": \"原因说明\"",
" }",
" ]",
"}");
}
/// <summary>
/// 解析 LLM 返回的 JSON 响应为建议列表(公开方法,便于测试)。
/// </summary>
/// <param name="llmResponse">LLM 返回的原始文本。</param>
/// <returns>解析后的建议列表。</returns>
public static List<MeetingTaskSuggestion> ParseBreakdownJson(string llmResponse)
{
var json = CleanJsonResponse(llmResponse);
LlBreakdownResponse? result;
try
{
result = JsonSerializer.Deserialize<LlBreakdownResponse>(json, _jsonOptions);
}
catch (JsonException)
{
return new List<MeetingTaskSuggestion>();
}
if (result?.Suggestions == null || result.Suggestions.Count == 0)
return new List<MeetingTaskSuggestion>();
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();
}
/// <summary>
/// 清理 LLM 响应文本:移除可能的 markdown 代码块包裹、前后空白。
/// </summary>
/// <param name="response">原始 LLM 响应。</param>
/// <returns>清理后的 JSON 字符串。</returns>
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();
}
/// <summary>
/// LLM 原始响应结构(内部使用,仅用于 JSON 反序列化)。
/// </summary>
private class LlBreakdownResponse
{
public List<MeetingTaskSuggestion> Suggestions { get; set; } = new();
}
}
@@ -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;
/// <summary>
/// 会议业务服务实现,继承 <see cref="IMeetingService"/> 对外暴露为 Dynamic API 和 MCP 工具。
/// 负责会议纪要保存、音频转录、AI 任务拆分与确认创建的全流程。
/// </summary>
public class MeetingService : IMeetingService
{
private readonly ITaskRepository _taskRepository;
private readonly ISttService _sttService;
private readonly MeetingAiBreakdownService _breakdownService;
/// <summary>
/// 初始化 <see cref="MeetingService"/> 实例。
/// </summary>
/// <param name="taskRepository">待办项仓储,用于查询与更新 Meeting 类型的待办项。</param>
/// <param name="sttService">语音转写服务,将音频流转换为文字。</param>
/// <param name="breakdownService">AI 会议拆分服务,调用 LLM 分析会议内容并拆分待办项。</param>
public MeetingService(ITaskRepository taskRepository, ISttService sttService, MeetingAiBreakdownService breakdownService)
{
_taskRepository = taskRepository;
_sttService = sttService;
_breakdownService = breakdownService;
}
/// <summary>
/// 按 taskId 查询待办项并验证其类型为 Meeting,验证失败时抛出对应异常。
/// 多个公开方法共用此校验逻辑,避免重复的 null 检查和类型判断。
/// </summary>
/// <param name="taskId">待办项 ID。</param>
/// <returns>验证通过的 TaskEntity 实例。</returns>
/// <exception cref="KeyNotFoundException">当 taskId 对应的待办项不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当待办项存在但 TaskType 不是 Meeting 时抛出。</exception>
private async Task<TaskEntity> 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;
}
/// <inheritdoc />
/// <remarks>
/// 直接覆盖写入会议纪要,不做追加。如需追加场景请使用 <see cref="TranscribeAudioAsync"/>。
/// </remarks>
public async Task<SaveNotesResponse> 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
};
}
/// <inheritdoc />
/// <remarks>
/// 只返回会议维度的信息(纪要、时长、完成状态),不含子任务递归。
/// </remarks>
public async Task<MeetingDetailResponse> 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
};
}
/// <inheritdoc />
/// <remarks>
/// 转写结果以追加方式写入 MeetingNotes,支持同一会议多次录音的累积场景。
/// </remarks>
public async Task<TranscribeResponse> 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
};
}
/// <inheritdoc />
/// <remarks>
/// 委托 <see cref="MeetingAiBreakdownService.AnalyzeAsync"/> 调用 LLM 分析会议内容,返回待办项建议列表。
/// 当 notes 参数为空时将使用已保存的 MeetingNotes 作为分析文本。
/// </remarks>
public async Task<BreakdownResponse> RequestBreakdownAsync(BreakdownRequest request)
{
var suggestions = await _breakdownService.AnalyzeAsync(request.TaskId, request.Notes);
return new BreakdownResponse
{
TaskId = request.TaskId,
Suggestions = suggestions
};
}
/// <inheritdoc />
/// <remarks>
/// 委托 <see cref="MeetingAiBreakdownService.ConfirmAndCreateAsync"/> 批量创建子任务,
/// 所有子任务自动设为 <see cref="TaskType.Normal"/> 类型并以当前会议为父任务。
/// </remarks>
public async Task<BatchCreateResult> ConfirmBreakdownAsync(ConfirmBreakdownRequest request)
{
return await _breakdownService.ConfirmAndCreateAsync(request.TaskId, request.SubTasks);
}
}
@@ -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;
/// <summary>
/// 保存会议纪要请求。
/// </summary>
public class SaveNotesRequest
{
/// <summary>
/// 待办项 ID。
/// </summary>
[Required]
public Guid TaskId { get; set; }
/// <summary>
/// 会议纪要/转写文字内容。
/// </summary>
[Required]
[MaxLength(20000)]
public string Notes { get; set; } = string.Empty;
}
/// <summary>
/// 保存会议纪要响应。
/// </summary>
public class SaveNotesResponse
{
/// <summary>
/// 待办项 ID。
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 保存后的会议纪要内容。
/// </summary>
public string MeetingNotes { get; set; } = string.Empty;
}
/// <summary>
/// 会议详情响应(不含子任务递归)。
/// </summary>
public class MeetingDetailResponse
{
/// <summary>
/// 待办项 ID。
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 会议标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 会议纪要/转写文字。
/// </summary>
public string? MeetingNotes { get; set; }
/// <summary>
/// 录音时长(秒)。
/// </summary>
public double? AudioDuration { get; set; }
/// <summary>
/// 是否已完成。
/// </summary>
public bool IsCompleted { get; set; }
}
/// <summary>
/// 会议任务拆分建议(LLM 输出)。
/// </summary>
public class MeetingTaskSuggestion
{
/// <summary>建议的待办项标题</summary>
public string Title { get; set; } = string.Empty;
/// <summary>建议优先级</summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>拆分原因(LLM 输出,用于 UI 展示)</summary>
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// AI 拆分请求。
/// </summary>
public class BreakdownRequest
{
/// <summary>待办项 ID</summary>
[Required]
public Guid TaskId { get; set; }
/// <summary>会议纪要文字(可选,不传则使用已保存的 meetingNotes</summary>
public string? Notes { get; set; }
}
/// <summary>
/// AI 拆分响应。
/// </summary>
public class BreakdownResponse
{
/// <summary>待办项 ID</summary>
public Guid TaskId { get; set; }
/// <summary>拆分建议列表</summary>
public List<MeetingTaskSuggestion> Suggestions { get; set; } = new();
}
/// <summary>
/// 批量创建请求中的单个子任务项。
/// </summary>
public class SubTaskCreateItem
{
/// <summary>子任务标题</summary>
[Required]
public string Title { get; set; } = string.Empty;
/// <summary>子任务优先级</summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
}
/// <summary>
/// 批量确认拆分请求。
/// </summary>
public class ConfirmBreakdownRequest
{
/// <summary>待办项 ID</summary>
[Required]
public Guid TaskId { get; set; }
/// <summary>要创建的子任务列表</summary>
[Required]
public List<SubTaskCreateItem> SubTasks { get; set; } = new();
}
/// <summary>
/// 批量创建结果。
/// </summary>
public class BatchCreateResult
{
/// <summary>成功创建数量</summary>
public int CreatedCount { get; set; }
/// <summary>创建后的子任务 DTO 列表</summary>
public List<TaskDto> SubTasks { get; set; } = new();
}
/// <summary>
/// 语音转写请求。
/// 音频数据以 Base64 编码在 JSON 中传输,避免 DynamicApi 中间件处理 multipart/form-data 的兼容问题。
/// </summary>
public class TranscribeRequest
{
/// <summary>
/// 待办项 ID。
/// </summary>
[Required]
public Guid TaskId { get; set; }
/// <summary>
/// Base64 编码的音频数据。
/// </summary>
[Required]
public string AudioBase64 { get; set; } = string.Empty;
/// <summary>
/// 音频格式,例如 "webm"、"mp4"、"wav"。
/// </summary>
[Required]
public string Format { get; set; } = string.Empty;
/// <summary>
/// 音频时长(秒),由前端录制时计算。
/// </summary>
public double AudioDuration { get; set; }
}
/// <summary>
/// 语音转写响应。
/// </summary>
public class TranscribeResponse
{
/// <summary>
/// 待办项 ID。
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 转写后的文字内容。
/// </summary>
public string Transcript { get; set; } = string.Empty;
/// <summary>
/// 音频时长(秒)。
/// </summary>
public double AudioDuration { get; set; }
}
@@ -0,0 +1,24 @@
namespace Hua.Todo.Application.Services.Meeting;
/// <summary>
/// 语音转写服务占位实现。
/// 当前返回占位文本,后续版本将通过 LLM Whisper API 或平台原生 API 实现真实转写。
/// </summary>
public class SttService : ISttService
{
/// <inheritdoc />
public async Task<string> 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} 字节,请手动输入会议纪要。";
}
}
@@ -1,24 +1,27 @@
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models; using Hua.Todo.Application.Models;
using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces; using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Services; namespace Hua.Todo.Application.Services;
/// <summary> /// <summary>
/// 任务管理服务实现 /// 任务管理服务实现
/// </summary> /// </summary>
public class TaskService : ITaskService public class TaskService : ITaskService
{ {
private readonly ITaskRepository _taskRepository; private readonly ITaskRepository _taskRepository;
private readonly IAttachmentRepository? _attachmentRepository;
/// <summary> /// <summary>
/// 初始化任务管理服务的新实例 /// 初始化任务管理服务的新实例
/// </summary> /// </summary>
/// <param name="taskRepository">任务仓储接口</param> /// <param name="taskRepository">任务仓储接口</param>
public TaskService(ITaskRepository taskRepository) /// <param name="attachmentRepository">附件仓储接口(可选,静态服务端可能未注册)。</param>
public TaskService(ITaskRepository taskRepository, IAttachmentRepository? attachmentRepository = null)
{ {
_taskRepository = taskRepository; _taskRepository = taskRepository;
_attachmentRepository = attachmentRepository;
} }
/// <summary> /// <summary>
@@ -36,7 +39,7 @@ public class TaskService : ITaskService
/// </summary> /// </summary>
/// <param name="id">任务 ID</param> /// <param name="id">任务 ID</param>
/// <returns>找到的任务 DTO,如果不存在则返回 null</returns> /// <returns>找到的任务 DTO,如果不存在则返回 null</returns>
public async Task<TaskDto?> GetTaskByIdAsync(int id) public async Task<TaskDto?> GetTaskByIdAsync(Guid id)
{ {
var task = await _taskRepository.GetByIdAsync(id); var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null; return task != null ? MapToDto(task) : null;
@@ -69,14 +72,18 @@ public class TaskService : ITaskService
/// <returns>新创建的任务 DTO</returns> /// <returns>新创建的任务 DTO</returns>
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{ {
var maxCode = await _taskRepository.GetMaxCodeAsync(TodoUserIds.LocalUserId);
var task = new TaskEntity var task = new TaskEntity
{ {
Id = Guid.NewGuid(),
UserId = TodoUserIds.LocalUserId,
Title = dto.Title, Title = dto.Title,
Priority = dto.Priority, Priority = dto.Priority,
Code = (maxCode + 1).ToString(),
IsCompleted = false, IsCompleted = false,
CreatedAt = DateTime.UtcNow, ParentTaskId = dto.ParentTaskId,
UpdatedAt = DateTime.UtcNow, TaskType = dto.TaskType,
ParentTaskId = dto.ParentTaskId Description = dto.Description
}; };
var createdTask = await _taskRepository.AddAsync(task); var createdTask = await _taskRepository.AddAsync(task);
@@ -107,7 +114,17 @@ public class TaskService : ITaskService
task.Priority = dto.Priority.Value; 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); var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask); return MapToDto(updatedTask);
@@ -119,7 +136,7 @@ public class TaskService : ITaskService
/// <param name="id">任务 ID</param> /// <param name="id">任务 ID</param>
/// <returns>状态切换后的任务 DTO</returns> /// <returns>状态切换后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception> /// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> ToggleCompleteAsync(int id) public async Task<TaskDto> ToggleCompleteAsync(Guid id)
{ {
var task = await _taskRepository.GetByIdAsync(id); var task = await _taskRepository.GetByIdAsync(id);
if (task == null) if (task == null)
@@ -128,7 +145,6 @@ public class TaskService : ITaskService
} }
task.IsCompleted = !task.IsCompleted; task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task); var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask); return MapToDto(updatedTask);
@@ -139,7 +155,7 @@ public class TaskService : ITaskService
/// </summary> /// </summary>
/// <param name="id">任务 ID</param> /// <param name="id">任务 ID</param>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception> /// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task DeleteTaskAsync(int id) public async Task DeleteTaskAsync(Guid id)
{ {
var task = await _taskRepository.GetByIdAsync(id); var task = await _taskRepository.GetByIdAsync(id);
if (task == null) if (task == null)
@@ -155,26 +171,31 @@ public class TaskService : ITaskService
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务 ID</param> /// <param name="parentTaskId">父任务 ID</param>
/// <returns>子任务的 DTO 列表</returns> /// <returns>子任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId) public async Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
{ {
var allTasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList(); return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
} }
/// <summary> /// <summary>
/// 实体转 DTO 映射方法 /// 实体转 DTO 映射方法
/// </summary> /// </summary>
private TaskDto MapToDto(TaskEntity task) private TaskDto MapToDto(TaskEntity task)
{ {
return new TaskDto return new TaskDto
{ {
Id = task.Id, Id = task.Id,
Code = task.Code,
Title = task.Title, Title = task.Title,
Priority = task.Priority, Priority = task.Priority,
IsCompleted = task.IsCompleted, IsCompleted = task.IsCompleted,
CreatedAt = task.CreatedAt, CreatedAt = task.CreationTime,
UpdatedAt = task.UpdatedAt, UpdatedAt = task.LastModificationTime ?? task.CreationTime,
ParentTaskId = task.ParentTaskId, ParentTaskId = task.ParentTaskId,
TaskType = task.TaskType,
MeetingNotes = task.MeetingNotes,
AudioDuration = task.AudioDuration,
Description = task.Description,
SubTasks = task.SubTasks.Select(MapToDto).ToList() SubTasks = task.SubTasks.Select(MapToDto).ToList()
}; };
} }
@@ -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;
/// <summary>
/// AI 任务拆分服务。
/// 通过 LLM 分析父任务标题,生成子任务拆分建议,经用户确认后批量创建子任务。
/// </summary>
public class AiBreakdownService
{
private readonly ILlmClientService _llmClient;
private readonly ITaskService _taskService;
private readonly ILogger<AiBreakdownService> _logger;
/// <summary>
/// AI 拆分专用 system prompt。
/// 指示 LLM 分析任务并输出结构化子任务建议。
/// </summary>
private const string BreakdownPrompt = """
3-10
JSON JSON
{
"suggestions": [
{ "title": "子任务标题", "priority": "High|Medium|Low", "reason": "建议理由" }
]
}
""";
/// <summary>
/// 创建 <see cref="AiBreakdownService"/> 实例。
/// </summary>
/// <param name="llmClient">LLM 客户端服务。</param>
/// <param name="taskService">任务服务。</param>
/// <param name="logger">日志记录器。</param>
public AiBreakdownService(ILlmClientService llmClient, ITaskService taskService, ILogger<AiBreakdownService> logger)
{
_llmClient = llmClient;
_taskService = taskService;
_logger = logger;
}
/// <summary>
/// 获取任务拆分建议。
/// 通过 LLM 分析父任务标题,生成 3-10 条结构化子任务建议。
/// </summary>
/// <param name="request">拆分请求,包含目标任务 ID。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>AI 拆分响应,包含建议列表。</returns>
public async Task<AiBreakdownResponse> 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;
}
/// <summary>
/// 确认拆分建议并批量创建子任务。
/// </summary>
/// <param name="request">确认请求,包含父任务 ID 和选定的子任务列表。</param>
/// <returns>创建后的子任务 DTO 列表。</returns>
public async Task<List<TaskDto>> ConfirmBreakdownAsync(AiBreakdownConfirmRequest request)
{
var created = new List<TaskDto>();
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;
}
/// <summary>
/// 解析 LLM 返回的 JSON 为子任务建议列表。
/// </summary>
private static List<AiBreakdownSuggestion> 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<AiBreakdownSuggestion>();
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<AiBreakdownSuggestion>();
}
}
}
@@ -0,0 +1,75 @@
using Hua.Todo.Core.Services;
using Hua.Todo.Core.Voice;
using Microsoft.Extensions.Logging;
namespace Hua.Todo.Application.Services.Voice;
/// <summary>
/// 双策略语音意图解析器。
/// 在线时优先使用 LLM 解析,失败或离线时降级到规则匹配。
/// 策略切换通过配置项 <c>Voice:Online</c> 控制(默认 true)。
/// </summary>
public class HybridVoiceIntentParser : IVoiceIntentParser
{
private readonly LlmIntentParser _llmParser;
private readonly RuleIntentParser _ruleParser;
private readonly ILogger<HybridVoiceIntentParser> _logger;
private readonly bool _isOnline;
/// <summary>
/// 创建 <see cref="HybridVoiceIntentParser"/> 实例。
/// </summary>
/// <param name="llmParser">LLM 意图解析器(在线策略)。</param>
/// <param name="ruleParser">规则意图解析器(离线降级策略)。</param>
/// <param name="logger">日志记录器。</param>
public HybridVoiceIntentParser(LlmIntentParser llmParser, RuleIntentParser ruleParser, ILogger<HybridVoiceIntentParser> 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;
}
}
/// <summary>
/// 解析语音指令:在线时优先 LLM,失败或离线时降级规则匹配。
/// </summary>
/// <param name="text">STT 识别后的原始文字。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>解析结果。</returns>
public async Task<VoiceIntentResult> 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);
}
}
@@ -0,0 +1,16 @@
namespace Hua.Todo.Application.Services.Voice;
/// <summary>
/// LLM HTTP 客户端服务接口。
/// 封装外部 LLM API 调用,供意图解析器与 AI 拆分服务共用。
/// </summary>
public interface ILlmClientService
{
/// <summary>
/// 向 LLM 发送 prompt 并返回原始响应文本。
/// </summary>
/// <param name="prompt">用户 prompt 文本。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>LLM 返回的原始文本。</returns>
Task<string> SendAsync(string prompt, CancellationToken ct = default);
}
@@ -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;
/// <summary>
/// 语音控制 API 服务接口(Dynamic API 自动发现)。
/// 提供语音指令解析执行、歧义确认和 AI 拆分端点。
/// </summary>
public interface IVoiceService : IDynamicApiService
{
/// <summary>
/// 解析并执行语音指令。
/// POST /api/voice/command
/// </summary>
/// <param name="request">语音指令请求,包含 STT 识别后的原始文字。</param>
/// <returns>解析执行结果。</returns>
[System.ComponentModel.Description("解析并执行语音指令")]
[HttpPost("command")]
Task<VoiceCommandResponse> ExecuteCommandAsync(VoiceCommandRequest request);
/// <summary>
/// 确认歧义指令并执行。
/// POST /api/voice/command/confirm
/// </summary>
/// <param name="request">确认请求,包含意图和用户选择的任务 ID。</param>
/// <returns>执行结果。</returns>
[System.ComponentModel.Description("确认歧义语音指令并执行")]
[HttpPost("command/confirm")]
Task<VoiceCommandResponse> ConfirmCommandAsync(VoiceCommandConfirmRequest request);
/// <summary>
/// 获取 AI 任务拆分建议。
/// POST /api/voice/ai-breakdown
/// </summary>
/// <param name="request">拆分请求,包含目标任务 ID。</param>
/// <returns>AI 拆分建议列表。</returns>
[System.ComponentModel.Description("获取 AI 任务拆分建议")]
[HttpPost("ai-breakdown")]
Task<AiBreakdownResponse> GetBreakdownAsync(AiBreakdownRequest request);
/// <summary>
/// 确认 AI 拆分建议并批量创建子任务。
/// POST /api/voice/ai-breakdown/confirm
/// </summary>
/// <param name="request">确认请求,包含父任务 ID 和选定的子任务。</param>
/// <returns>创建后的子任务列表。</returns>
[System.ComponentModel.Description("确认 AI 拆分建议并批量创建子任务")]
[HttpPost("ai-breakdown/confirm")]
Task<List<TaskDto>> ConfirmBreakdownAsync(AiBreakdownConfirmRequest request);
}
@@ -0,0 +1,82 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
namespace Hua.Todo.Application.Services.Voice;
/// <summary>
/// LLM HTTP 客户端实现。
/// 通过配置读取 LLM 端点与 API Key,发送 prompt 并返回原始响应文本。
/// 支持标准 OpenAI 兼容 API 格式。
/// </summary>
public class LlmClientService : ILlmClientService
{
private readonly HttpClient _httpClient;
private readonly string _endpoint;
private readonly string _apiKey;
private readonly string _model;
/// <summary>
/// 创建 <see cref="LlmClientService"/> 实例。
/// </summary>
/// <param name="httpClient">HTTP 客户端。</param>
/// <param name="configuration">应用配置。</param>
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}");
}
}
/// <summary>
/// 向 LLM 发送 prompt 并返回原始响应文本。
/// </summary>
/// <param name="prompt">用户 prompt 文本。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>LLM 返回的原始文本。</returns>
/// <exception cref="InvalidOperationException">当 LLM 端点未配置时抛出。</exception>
/// <exception cref="HttpRequestException">当 HTTP 请求失败时抛出。</exception>
public async Task<string> 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();
}
}
@@ -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;
/// <summary>
/// 基于 LLM 的意图解析器(在线策略)。
/// 通过 system prompt 约束 LLM 输出结构化 JSON,提取意图与参数。
/// </summary>
public class LlmIntentParser : IVoiceIntentParser
{
private readonly ILlmClientService _llmClient;
private readonly ILogger<LlmIntentParser> _logger;
/// <summary>
/// 用于约束 LLM 输出的 system prompt。
/// 指示 LLM 解析用户自然语言指令并返回 JSON。
/// </summary>
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
""";
/// <summary>
/// 创建 <see cref="LlmIntentParser"/> 实例。
/// </summary>
/// <param name="llmClient">LLM 客户端服务。</param>
/// <param name="logger">日志记录器。</param>
public LlmIntentParser(ILlmClientService llmClient, ILogger<LlmIntentParser> logger)
{
_llmClient = llmClient;
_logger = logger;
}
/// <summary>
/// 使用 LLM 解析语音指令文本。
/// </summary>
/// <param name="text">STT 识别后的原始文字。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>解析结果。解析失败时返回 UNKNOWN 意图。</returns>
public async Task<VoiceIntentResult> 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
};
}
}
/// <summary>
/// 解析 LLM 返回的 JSON 文本为 <see cref="VoiceIntentResult"/>。
/// 对格式不正确的响应做容错处理。
/// </summary>
/// <param name="llmResponse">LLM 原始响应文本。</param>
/// <param name="originalText">用户原始输入文本。</param>
/// <returns>解析结果。</returns>
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<VoiceIntent>(intentStr, true, out var parsed) ? parsed : VoiceIntent.UNKNOWN;
var confidence = root.TryGetProperty("confidence", out var confProp)
? confProp.GetDouble()
: 0.5;
var paramsDict = new Dictionary<string, string>();
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
};
}
}
}
@@ -0,0 +1,79 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.Services.Voice.Models;
/// <summary>
/// AI 任务拆分请求 DTO。
/// </summary>
public class AiBreakdownRequest
{
/// <summary>
/// 要拆分的父任务 ID。
/// </summary>
public Guid TaskId { get; set; }
}
/// <summary>
/// AI 任务拆分响应 DTO。
/// 包含 LLM 生成的子任务建议列表。
/// </summary>
public class AiBreakdownResponse
{
/// <summary>
/// LLM 生成的子任务建议列表。
/// </summary>
public List<AiBreakdownSuggestion> Suggestions { get; set; } = new();
}
/// <summary>
/// AI 生成的单个子任务建议。
/// </summary>
public class AiBreakdownSuggestion
{
/// <summary>
/// 子任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 建议优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// LLM 给出的建议理由(可选)。
/// </summary>
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// 用户确认拆分建议后批量创建的请求 DTO。
/// </summary>
public class AiBreakdownConfirmRequest
{
/// <summary>
/// 父任务 ID。
/// </summary>
public Guid ParentTaskId { get; set; }
/// <summary>
/// 用户确认要创建的子任务列表。
/// </summary>
public List<SubTaskItem> SubTasks { get; set; } = new();
}
/// <summary>
/// 确认创建的子任务项。
/// </summary>
public class SubTaskItem
{
/// <summary>
/// 子任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 子任务优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
}
@@ -0,0 +1,34 @@
namespace Hua.Todo.Application.Services.Voice.Models;
/// <summary>
/// 语音指令请求 DTO。
/// 前端将 STT 识别后的原始文字通过此模型发送到后端。
/// </summary>
public class VoiceCommandRequest
{
/// <summary>
/// STT 识别后的原始文字(自然语言)。
/// </summary>
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// 用户确认歧义后的二次请求 DTO。
/// </summary>
public class VoiceCommandConfirmRequest
{
/// <summary>
/// 上次解析出的意图。
/// </summary>
public string Intent { get; set; } = string.Empty;
/// <summary>
/// 用户选择的任务 ID。
/// </summary>
public Guid TargetId { get; set; }
/// <summary>
/// 可选:修改后的标题(用于 UPDATE 意图)。
/// </summary>
public string? NewTitle { get; set; }
}
@@ -0,0 +1,56 @@
using Hua.Todo.Core.Voice;
namespace Hua.Todo.Application.Services.Voice.Models;
/// <summary>
/// 语音指令响应 DTO。
/// 包含意图解析结果、执行结果及可能的歧义信息。
/// </summary>
public class VoiceCommandResponse
{
/// <summary>
/// 解析出的意图。
/// </summary>
public VoiceIntent Intent { get; set; }
/// <summary>
/// 提取的参数。
/// </summary>
public Dictionary<string, string> Params { get; set; } = new();
/// <summary>
/// 置信度(0.0 ~ 1.0)。
/// </summary>
public double Confidence { get; set; }
/// <summary>
/// 执行结果。
/// </summary>
public VoiceExecutionResult Result { get; set; } = new();
}
/// <summary>
/// 语音指令执行结果。
/// </summary>
public class VoiceExecutionResult
{
/// <summary>
/// 是否执行成功。
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 结果消息(成功时为确认信息,失败时为错误描述)。
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// 是否存在歧义。
/// </summary>
public bool Ambiguity { get; set; }
/// <summary>
/// 歧义时的候选列表。
/// </summary>
public List<VoiceCandidate> Candidates { get; set; } = new();
}
@@ -0,0 +1,120 @@
using System.Text.RegularExpressions;
using Hua.Todo.Core.Services;
using Hua.Todo.Core.Voice;
namespace Hua.Todo.Application.Services.Voice;
/// <summary>
/// 基于正则表达式的规则意图解析器(离线/降级策略)。
/// 覆盖高频 CRUD 与子任务指令,AI_BREAKDOWN 在离线模式下不可用。
/// </summary>
public class RuleIntentParser : IVoiceIntentParser
{
/// <summary>
/// 意图匹配规则列表。
/// 按优先级排列,第一个匹配成功的规则将被使用。
/// </summary>
private static readonly List<(Regex Pattern, VoiceIntent Intent, Func<Match, Dictionary<string, string>> ParamsExtractor)> Rules = new()
{
// UPDATE:修改/更新/编辑 xxx 改为 yyy
(new Regex(@"^(修改|更新|编辑)\s*(任务|待办)?\s*(.+?)\s*(改为|改成|改成)\s*(.+)", RegexOptions.Compiled),
VoiceIntent.UPDATE,
m => new Dictionary<string, string>
{
["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<string, string>
{
["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<string, string>
{
["targetTitle"] = m.Groups[2].Value.Trim()
}),
// CREATE:创建/新增/添加/加 xxx
(new Regex(@"^(创建|新增|添加|加)\s*(任务|待办)?\s*(.+)", RegexOptions.Compiled),
VoiceIntent.CREATE,
m => new Dictionary<string, string>
{
["title"] = m.Groups[3].Value.Trim()
}),
// DELETE:删除/删掉/移除 xxx
(new Regex(@"^(删除|删掉|移除)\s*(任务|待办)?\s*(.+)", RegexOptions.Compiled),
VoiceIntent.DELETE,
m => new Dictionary<string, string>
{
["targetTitle"] = m.Groups[3].Value.Trim()
}),
// COMPLETE:完成/做完 xxx
(new Regex(@"^(完成|做完)\s*(任务|待办)?\s*(.+)", RegexOptions.Compiled),
VoiceIntent.COMPLETE,
m => new Dictionary<string, string>
{
["targetTitle"] = m.Groups[3].Value.Trim()
}),
// UNCOMPLETE:取消完成/重新打开/恢复 xxx
(new Regex(@"^(取消完成|重新打开|恢复)\s*(.+)", RegexOptions.Compiled),
VoiceIntent.UNCOMPLETE,
m => new Dictionary<string, string>
{
["targetTitle"] = m.Groups[2].Value.Trim()
}),
// QUERY:查询/查看/列出/显示 xxx
(new Regex(@"^(查询|查看|列出|显示)\s*(.+)", RegexOptions.Compiled),
VoiceIntent.QUERY,
m => new Dictionary<string, string>
{
["filter"] = m.Groups[2].Value.Trim()
}),
};
/// <summary>
/// 使用正则规则解析语音指令文本。
/// </summary>
/// <param name="text">STT 识别后的原始文字。</param>
/// <param name="ct">取消令牌(规则解析为同步操作,忽略此参数)。</param>
/// <returns>解析结果。匹配失败时返回 UNKNOWN 意图。</returns>
public Task<VoiceIntentResult> 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
});
}
}

Some files were not shown because too many files have changed in this diff Show More