Compare commits

..

2 Commits

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

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
2026-06-21 03:26:04 +08:00
ShaoHua 65cee20006 docs: 重组 docs/manual/ 指南结构,区分普通用户与开发者双入口
- 新增 00-目录与导读.md 双入口导航

- 用户面(01-04):项目介绍、安装指南、版本记录、其他信息

- 开发者面(05-10):技术栈、构建、架构、云同步、代码规范、MCP

- 拆分旧01为 01(用户)+05(开发者);旧02为 02(用户)+06(开发者)

- 合并旧08+09 MCP文档为 10-MCP服务集成

- 同步更新 README.md 与 .trae/rules/项目/ 交叉引用
2026-06-16 01:46:46 +08:00
217 changed files with 13914 additions and 4778 deletions
+2
View File
@@ -366,3 +366,5 @@ FodyWeavers.xsd
/Hua.Todo/Output
/src/Hua.Todo.Maui/Output
/src/Hua.Todo.Host/Hua.Todo.db
src/Hua.Todo.Host/Hua.Todo.db-wal
src/Hua.Todo.Host/Hua.Todo.db-shm
+1
View File
@@ -0,0 +1 @@
{}
@@ -63,7 +63,7 @@
| 子目录 | 当前最大序号 | 下一个可用 |
|---|---|---|
| `rules/全局/` | 06 | 07 |
| `rules/全局/` | 07 | 08 |
| `rules/项目/` | 05 | 06 |
| `memory/` | 01 | 02 |
| `coordination/` | 02 | 03 |
+4 -2
View File
@@ -17,9 +17,11 @@ description: 强制项目注释规范(C# / TypeScript):新增或修改代
## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途
-关键参数/返回值:`param` / `returns`
- `summary`:一句话说明用途,不允许重复嵌套 `<summary>` 标签
- 对参数/返回值:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
- XML 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明,避免 `///` 落在特性之后导致 CS1587。
- `<see cref="..."/>` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574。
-**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#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)
+30 -1
View File
@@ -3,7 +3,7 @@
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 描述当前仓库的项目划分、依赖方向、运行模式与跨平台策略,供智能体在做改动时快速对齐架构边界。
> 完整设计参见 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md) 与 [docs/manual/03-技术栈与模块.md](../../../docs/manual/03-技术栈与模块.md)。
> 完整设计参见 [docs/manual/05-技术栈与项目结构.md](../../../docs/manual/05-技术栈与项目结构.md) 与 [docs/manual/07-技术架构设计.md](../../../docs/manual/07-技术架构设计.md)。
## 一、项目清单(src/
@@ -73,3 +73,32 @@ Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主)
- Inno Setup 安装包:`src/Hua.Todo.Maui/setup.iss``src/Hua.Todo.Avalonia/setup.iss`
- Linux`publish-linux.ps1` 产出 `.tar.gz``pack/linux/` 含 Flatpak 基础结构
- 详见 [docs/project/研发工单-v1.2.0/02.1-版本统一与打包方案.md](../../../docs/project/研发工单-v1.2.0/02.1-版本统一与打包方案.md)
## 七、测试项目架构
### 7.1 目录结构
```
test/ ← 项目根目录下的顶层测试目录
├── Hua.Todo.Host.Tests/ ← 服务端/Application 层集成测试
│ ├── CloudSync/ ← 云同步模块
│ ├── Meeting/ ← 会议模块
│ └── Attachments/ ← 附件模块
├── Hua.Todo.Maui.Tests/ ← MAUI 平台测试(骨架)
└── Hua.Todo.Avalonia.Tests/ ← Avalonia 平台测试(骨架)
```
### 7.2 分层规则(严禁跨层)
| 测试项目 | 可引用的被测项目 | 不得引用 |
|---|---|---|
| `Hua.Todo.Host.Tests` | `Hua.Todo.Host` / `Application` / `Core` | MAUI / Avalonia 宿主 |
| `Hua.Todo.Maui.Tests` | `Hua.Todo.Maui` / `Application` / `Core` | Avalonia 宿主 |
| `Hua.Todo.Avalonia.Tests` | `Hua.Todo.Avalonia` / `Application` / `Core` | MAUI 宿主 |
### 7.3 技术栈与规范
- 框架:xUnit 2.9+SQLite In-Memory 模拟 DB
- 测试层次:直接测 Application 服务层(DI 测试),必要时用 `WebApplicationFactory`
- 命名:类 `{被测类}Tests`、方法 `{方法名}_{场景}_{预期结果}`、命名空间 `Hua.Todo.Host.Tests.{模块名}`
- 文件组织:每个模块新建子目录,模块级共用测试放根目录
+1 -1
View File
@@ -31,7 +31,7 @@
### 2.3 HTTP API 路由
- 本地动态 API`/api/task``/api/task/{parentTaskId}/subtasks`(由 `Hua.Todo.Application/DynamicApi` 自动暴露)
- 云端:`GET /tasks``POST /sync``POST /cloud-sync/probe`
- 见 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md)、[docs/project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md](../../../docs/project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md)
- 见 [docs/manual/05-技术栈与项目结构.md](../../../docs/manual/05-技术栈与项目结构.md)、[docs/project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md](../../../docs/project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md)
### 2.4 数据库表
- `Tasks``Users``UserSessions``SecurityPolicies``AuditLogs`
@@ -94,6 +94,6 @@
1. [ ] 是否新增了 EF Core 迁移(而非手改快照)?
2. [ ] 迁移名称是否以 `AddXxx`/`UpdateXxx`/`RemoveXxx` 开头?
3. [ ] 是否在 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md) 中同步更新数据模型描述?
4. [ ] 是否在 [docs/manual/06-版本记录.md](../../../docs/manual/06-版本记录.md) 中追加非琐碎变更条目?
3. [ ] 是否在 [docs/manual/07-技术架构设计.md](../../../docs/manual/07-技术架构设计.md) 中同步更新数据模型描述?
4. [ ] 是否在 [docs/manual/03-版本记录.md](../../../docs/manual/03-版本记录.md) 中追加非琐碎变更条目?
5. [ ] 是否在嵌入式宿主上验证了启动时 Migrate 不报错?
+84 -3
View File
@@ -37,13 +37,13 @@
| 子工单 | 实现状态 | 验证状态 | 简要说明 |
|---|---|---|---|
| 01 - HTTP 服务转换 MCP 服务 | 进行中 | 待验证 | 将现有 HTTP API 映射为 MCP 工具描述符 |
| 02 - 语音控制与 AI 辅助 | 待开始 | 待验证 | STT/TTS + 语音指令解析 + AI 辅助任务拆分 |
| 02 - 语音控制与 AI 辅助 | 已完成(核心基础设施) | 待验证 | Core 接口(IVoiceInputService/IVoiceOutputService/IVoiceIntentParser)、VoiceIntent 枚举与 DTO、LLM 客户端(LlmClientService)、双策略意图解析器(LlmIntentParser + RuleIntentParser + HybridVoiceIntentParser)、VoiceCommandExecutor、AiBreakdownService、VoiceServiceDynamic API)、DI 注册;26 个单元测试通过;平台 STT/TTS 实现待补 |
| 03 - 会议任务拆分 | 待开始 | 待验证 | 会议类型入口 + 录音/文字输入 + AI 拆分建议 + 确认批量创建 |
| 03-01 - 会议数据模型与 API | 待开始 | 待验证 | TaskType 枚举、MeetingNotes/AudioDuration 字段、MeetingController |
| 03-02 - 音频录制与转写 | 待开始 | 待验证 | 前端 MediaRecorder 录音 + 后端 STT 转写 |
| 03-03 - AI 任务拆分服务 | 待开始 | 待验证 | 会议专用 LLM prompt + 批量创建子任务 |
| 03-04 | 任务建议与确认 UI | 待开始 | 待验证 | 录音/纪要/审阅对话框 + Meeting 类型条件渲染 |
| 04 | 富文本描述、附件与外部链接 | 待开始 | 待验证 | 多行描述 + 附件上传下载删除 + 外部链接 + 桌面端 Process.Start/xdg-open |
| 03-04 | 任务建议与确认 UI | 已完成 | 待验证 | MeetingBreakdownDialog.vue + meeting.ts 拆分/确认 API;待集成到 TaskItem |
| 04 | 富文本描述、附件与外部链接 | 已完成 | 待验证 | Description 字段 + AttachmentEntity 模型 + 附件 CRUD API + 外部链接 + 平台文件打开器(Maui/Avalonia+ 前端类型和 API 模块完善 + EF 迁移 + 19 个单元测试通过 |
## 四、关键临时决策
@@ -60,6 +60,35 @@
- [ ] Linux Flatpak/AppImage 自包含产物在干净环境的实测验证(v1.2.0 验收 Linux 部分仍为"待验证"
- [x] CloudSync UNIQUE 约束修复(2026-06-14):修复了 `existingTasks` 查询在事务外导致并发重同步时 `T_Tasks.Id` UNIQUE 约束冲突;新增 7 个测试(含 5 个 SQLite 集成测试)
- [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):
@@ -70,6 +99,58 @@
- **`.trae` 子目录文件序号化**(2026-06):
- `.trae/rules/全局/``.trae/rules/项目/``.trae/coordination/``.trae/memory/` 下所有文件加 `NN-` 序号前缀
- `.trae/索引.md` 不带序号(入口文件)
- **`docs/manual/` 指南重组**2026-06-16):
- 新增 `00-目录与导读.md` 双入口导航(普通用户 / 开发者)
- 拆分用户面与开发者面:01-02 为用户安装使用;05-10 为开发者架构/构建/规范
- 合并重叠与过时内容,精简用户文档篇幅
- 同步更新 README.md 与 `.trae/rules/项目/` 中的交叉引用
- **全局 Serilog 文件日志**2026-06-16):
- Application 层新增 `LoggingConfiguration.cs` 统一日志配置(Console + 按天滚动文件)
- Host / MAUI / Avalonia 宿主层全部接入 Serilog,日志目录 `logs/`Host)或 `{LocalApplicationData}/Hua.Todo/logs/`(客户端)
- 修复 `DynamicApiMiddleware` 及 Application 层 8 个服务类的 catch 块(原静默吞异常→记日志)
- 修复 MAUI 端 2 个 WebServer 文件 + Avalonia 端 `App.axaml.cs` / `EmbeddedWebServerService` 的 Console/Debug→Serilog
- 修复 `CloudTaskSyncService` SQL UNIQUE 约束冲突重试日志
- 144 个测试全部通过
- **Host 云同步端点补齐 + 测试目录迁移 + 规则新增**(2026-06-17):
- **问题**`Hua.Todo.Host/Program.cs` 未调用 `AddCloudSyncServer()``MapCloudSyncEndpoints()`,导致 `/auth/*` `/tasks/*` `/sync/*` `/security/*` `/cloud-sync/*` 全部 404
- **修复**Program.cs 新增 `AddCloudSyncServer()` + `MapCloudSyncEndpoints()` + `MapCloudSyncProxySettings()``vite.config.ts` 新增 6 条云同步路径代理
- **测试**`CloudSyncEndpointRegistrationTests.cs` 新增 5 个 DI 注册测试(完整 AddApplicationServices + AddCloudSyncServer 链路验证),157→157 全部通过
- **目录迁移**`src/test/``test/`(上移一级),更新 3 个 `.csproj``<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) |
| [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 |
| [06-AI沟通记录规范.md](./rules/全局/06-AI沟通记录规范.md) | 用户与智能体沟通记录的存储目录、序号管理与内容规范 |
| [07-代码实现与测试先行规范.md](./rules/全局/07-代码实现与测试先行规范.md) | 触发条件 + ATDD 工作流(RED→GREEN→REFACTOR)+ 验收测试用例规范 + 工单完成判定(合并原 07+08) |
---
@@ -45,11 +46,12 @@
| 文件 | 职责 |
|---|---|
| [01-项目架构.md](./rules/项目/01-项目架构.md) | src/ 五大项目划分、依赖方向、两种运行模式 |
| [01-项目架构.md](./rules/项目/01-项目架构.md) | src/ 项目清单、依赖方向、两种运行模式、跨平台原则、关键扩展点、测试项目架构 |
| [02-业务命名规范.md](./rules/项目/02-业务命名规范.md) | `Task`/`SubTask`/`TaskEntity` 等代码标识符与"研发工单"边界 |
| [03-数据模型与迁移约束.md](./rules/项目/03-数据模型与迁移约束.md) | EF Core 实体清单、迁移历史、改 schema 纪律 |
| [04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) | 当前活跃版本 / 工单状态快照 / 临时决策 / 未完结事项 |
| [05-多入口功能同步规范.md](./rules/项目/05-多入口功能同步规范.md) | 新增功能时必须同步确认 UI 入口与语音控制入口的覆盖情况 |
| [06-测试项目分层规范.md](./rules/项目/06-测试项目分层规范.md) | 测试项目目录结构、分层规则(禁止跨层)、技术栈与命名规范 |
---
+4 -1
View File
@@ -14,8 +14,11 @@
<!-- 编译优化选项 -->
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现文件正由另一进程使用 (XARDF7024) 的问题。 -->
<!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现"文件正由另一进程使用" (XARDF7024) 的问题。 -->
<MSBuildDisableNodeReuse>true</MSBuildDisableNodeReuse>
<!-- 禁止 SDK 自动生成 AssemblyInfo,避免与 Directory.Build.props 中的元数据属性冲突(CS0579)。 -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
+16 -4
View File
@@ -5,14 +5,26 @@
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj">
<Build Solution="Debug|*" Project="true" />
</Project>
<Folder Name="/src/" />
<Folder Name="/src/Domain/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.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.Maui/Hua.Todo.Maui.csproj">
<Build Solution="Debug|*" Project="false" />
</Project>
</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>
+15 -7
View File
@@ -83,7 +83,7 @@ Hua.Todo/
- **QQ 交流群**2167048911 (Hua.Todo 交流群)
- **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/04-其他信息.md)
## 📄 开源协议
@@ -91,12 +91,20 @@ Hua.Todo/
## 📚 更多文档
### 用户与开发者手册
- [技术栈与模块说明](docs/manual/技术栈与模块.md)
- [版本更新历史](docs/manual/版本记录.md)
- [技术设计文档](docs/manual/技术设计文档.md)
- [代码规范文档](docs/manual/代码规范文档.md)
- [其他信息 (贡献、许可证、联系方式)](docs/manual/其他信息.md)
### 普通用户
- [00-目录与导读](docs/manual/00-目录与导读.md) — 文档入口
- [01-项目介绍](docs/manual/01-项目介绍.md)
- [02-安装指南](docs/manual/02-安装指南.md)
- [03-版本记录](docs/manual/03-版本记录.md)
- [04-其他信息](docs/manual/04-其他信息.md)
### 开发者
- [05-技术栈与项目结构](docs/manual/05-技术栈与项目结构.md)
- [06-开发环境与构建](docs/manual/06-开发环境与构建.md)
- [07-技术架构设计](docs/manual/07-技术架构设计.md)
- [08-云同步规则](docs/manual/08-云同步规则.md)
- [09-代码规范](docs/manual/09-代码规范.md)
- [10-MCP服务集成](docs/manual/10-MCP服务集成.md)
### 项目进度与需求
- [产品需求文档](docs/project/产品需求文档.md)
@@ -0,0 +1,107 @@
# AI沟通记录:02-云同步链路问题分析
- **日期**2026-06-21
- **参与者**:用户、AI 助手
- **会话序号**02
## 讨论主题
用户反馈云同步(CloudSync)存在问题,对完整链路进行分析定位根因。
## 关键决策
无(本次为问题分析,未涉及改动决策)。
## 待办事项
- [ ] 配置 MAUI 端 `CloudSyncUrl` 指向 Host 地址(通过 UI 或配置文件)
- [ ] 考虑在 dev 模式下默认配置合理值,降低首次使用门槛
---
## 详细记录
### 01-当前运行环境
启动了三个服务:
| 服务 | 端口 | 终端 | 说明 |
|---|---|---|---|
| Vite dev server | 5174 | terminal 2 | `npm run dev`proxy `/api``http://localhost:5057` |
| Host | 5173 | terminal 4 | `dotnet run src/Hua.Todo.Host`,完整 CloudSync 端点(`AddCloudSyncServer` |
| MAUI (Windows) | 5057 | terminal 5 | `dotnet run src/Hua.Todo.Maui`,内嵌 WebServer,仅有 CloudSync 代理(`AddCloudSyncProxy` |
### 02-云同步请求链路(当前状态)
```
Browser (localhost:5174)
│ POST /api/auth/login
│ GET /api/tasks/
│ POST /api/tasks
│ GET /api/security/policy
│ POST /api/cloud-sync/probe
Vite dev server (5174)
│ vite.config.ts: proxy '/api' → target: 'http://localhost:5057'
MAUI 内嵌 WebServer (5057)
│ UseCloudSyncProxy() 中间件拦截路径:
│ /api/auth, /api/tasks, /api/sync, /api/security, /api/cloud-sync
├─ CloudSyncUrl 已配置 → 代理转发到 Host(5173) → ✓ 正常
└─ CloudSyncUrl 为空 → 直接返回 503 → ✗ 当前状态
```
### 03-根因定位
**MAUI 的 `appsettings.json` 中未配置 `CloudSyncUrl`**
- [appsettings.json](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Maui/appsettings.json) 中 `WebServer` 节点缺少 `CloudSyncUrl` 字段
- `CloudSyncProxyService.CloudSyncUrl` 默认值为空字符串,setter 中 `string.IsNullOrWhiteSpace` 将其转为 `null`
- [CloudSyncProxyService.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs#L142-L148) 在 `UseCloudSyncProxy` 中间件中,`CloudSyncUrl` 为空时直接返回 **503 "cloud sync server URL not configured"**
### 04-本地 Todo API 不受影响
`/api/task`(本地 Todo CRUD)不在 CloudSyncProxy 的 `ProxyPathRoots` 中,由 `UseDynamicApi()` 直接处理,走 MAUI 本地 SQLite,正常工作。
### 05-完整架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ 请求路径分流 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ /api/task/* ──────────→ DynamicApi (MAUI 本地) → MAUI SQLite │
│ (本地 Todo CRUD) ✓ 正常 │
│ │
│ /api/auth/* UseCloudSyncProxy 拦截 │
│ /api/tasks/* ├─ CloudSyncUrl 为空 → 503 ✗ │
│ /api/security/* └─ CloudSyncUrl 已配 → 转发 Host ✓ │
│ /api/cloud-sync/* │
│ │
│ /api/cloudSyncProxySettings ──→ DynamicApi (MAUI 本地) │
│ (设置 CloudSyncUrl 用) ✓ 正常 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 06-修复方案
**方案一(推荐,已有 UI 支持)**:通过 CloudSyncSettingsDialog 配置
1. 打开云同步设置弹窗
2. 输入服务端地址:`http://localhost:5173`
3. 点击"保存并探测"
4. 后续云同步请求经 MAUI 代理→Host,链路贯通
**方案二**:直接修改 MAUI 的 `appsettings.json`
`WebServer` 节点添加:`"CloudSyncUrl": "http://localhost:5173"`
**方案三**(纯 dev 模式):切换 Vite proxy 目标
设置环境变量 `VITE_API_TARGET=http://localhost:5173`,让 Vite 直连 Host,绕过 MAUI 内嵌服务器。
注意:此模式下 Todo CRUD 走 Host 的 DB`src/Hua.Todo.Host/Hua.Todo.db`),而非 MAUI 的本地 DB。
### 07-设计层面的潜在改进点
1. **默认值优化**:在 dev 模式下,`CloudSyncUrl` 可默认指向 `http://localhost:5173`(Host 默认端口),减少首次手动配置。
2. **错误提示增强**:503 响应可携带更友好的错误信息,前端弹窗提示用户去云同步设置中配置服务端地址。
3. **端口冲突风险**:当 Host(5173) + MAUI(5057) + Avalonia(5057) 同时运行时,需注意数据库隔离与端口分配。
+44
View File
@@ -0,0 +1,44 @@
# Hua.Todo 文档指南
> 本文档是 Hua.Todo 项目手册的入口。根据您的身份选择对应的阅读路线。
---
## 我是普通用户
只想安装和使用 Hua.Todo?从这里开始:
| 序号 | 文档 | 内容 |
|---|---|---|
| 01 | [项目介绍](./01-项目介绍.md) | 这是什么、能做什么、支持哪些平台 |
| 02 | [安装指南](./02-安装指南.md) | 如何安装、配置云同步、常见问题 |
| 03 | [版本记录](./03-版本记录.md) | 更新了哪些内容 |
| 04 | [其他信息](./04-其他信息.md) | 贡献指南、许可证、联系方式 |
---
## 我是开发者
需要编译源码、了解架构或参与开发?从这里开始:
| 序号 | 文档 | 内容 |
|---|---|---|
| 05 | [技术栈与项目结构](./05-技术栈与项目结构.md) | 用了哪些技术、项目如何划分、依赖关系 |
| 06 | [开发环境与构建](./06-开发环境与构建.md) | 如何搭建环境、构建、部署服务端 |
| 07 | [技术架构设计](./07-技术架构设计.md) | 目录结构、API 设计、数据库、通信机制 |
| 08 | [云同步规则](./08-云同步规则.md) | 认证鉴权、同步工作流、安全策略 |
| 09 | [代码规范](./09-代码规范.md) | C# / TypeScript / Vue 编码规范 |
| 10 | [MCP服务集成](./10-MCP服务集成.md) | MCP 接口规范与前端集成 |
---
## 项目简介
Hua.Todo 是一个跨平台待办事项管理工具,支持:
- **多平台**Windows / macOS / Linux / Android / iOS
- **云同步**:多端数据同步,安全可控
- **语音控制**:语音指令操作待办项
- **AI 辅助**:通过 MCP 协议接入 AI 客户端
> 源码仓库:[https://git.we965.cn/Tools/Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
-414
View File
@@ -1,414 +0,0 @@
# Hua.Todo 技术设计文档 v1.1.0
## 1. 项目概述
本文档描述 Hua.Todo v1.1.0 的技术设计方案,包括项目文件目录结构、模块划分、技术选型和实现细节。
## 2. 技术栈
### 2.1 后端技术栈
- **开发语言**: C# 10
- **框架**: .NET 10
- **UI 框架**: MAUI (Multi-platform App UI) + Avalonia (Linux 支持)
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
- **API 框架**: ASP.NET Core Web API
- **数据访问**: Entity Framework Core
- **数据库**: SQLite (本地存储)
- **日志**: Serilog
- **依赖注入**: Microsoft.Extensions.DependencyInjection
### 2.2 前端技术栈
- **开发语言**: JavaScript/TypeScript
- **框架**: Vue.js 3
- **构建工具**: Vite
- **HTTP 客户端**: Axios
- **状态管理**: Pinia
- **UI 组件库**: Element Plus / Vant (移动端)
- **CSS 预处理器**: SCSS
## 3. 项目目录结构
```
Hua.Todo/
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ │ ├── 03-技术栈与模块.md
│ │ ├── 06-版本记录.md
│ │ ├── 01-技术设计文档.md(本文件)
│ │ ├── 04-代码规范文档.md
│ │ └── 05-部署文档.md
│ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md
│ └── ...
├── src/ # 源代码目录
│ ├── Hua.Todo.Maui/ # MAUI 主项目(跨平台入口)
│ │ ├── Platforms/ # 平台特定代码
│ │ │ ├── Windows/ # Windows 平台代码
│ │ │ │ ├── App.xaml # Windows 应用入口
│ │ │ │ └── Services/ # Windows 平台服务
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── MacCatalyst/ # macOS 平台代码
│ │ │ │ ├── App.xaml
│ │ │ │ └── Services/
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── Android/ # Android 平台代码
│ │ │ │ ├── MainActivity.cs
│ │ │ │ └── Services/
│ │ │ │ └── NotificationService.cs
│ │ │ └── iOS/ # iOS 平台代码
│ │ │ ├── AppDelegate.cs
│ │ │ └── Services/
│ │ │ └── NotificationService.cs
│ │ ├── Resources/ # 资源文件
│ │ │ ├── Images/ # 图片资源
│ │ │ ├── Styles/ # 样式资源
│ │ │ └── Fonts/ # 字体资源
│ │ ├── Controls/ # 自定义控件
│ │ │ └── WebViewContainer.xaml
│ │ ├── Services/ # 服务层
│ │ │ ├── IHotKeyService.cs
│ │ │ ├── IPlatformService.cs
│ │ │ └── AppLifecycleService.cs
│ │ ├── App.xaml # MAUI 应用入口
│ │ ├── App.xaml.cs
│ │ ├── MauiProgram.cs # MAUI 程序配置
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
│ │
│ ├── Hua.Todo.Avalonia/ # Avalonia 项目(Linux 支持)
│ │ ├── Assets/ # 资源文件
│ │ │ └── icon.ico # 应用图标
│ │ ├── Services/ # 服务层
│ │ │ ├── EmbeddedWebServerServiceFactory.cs
│ │ │ ├── GlobalHotKeyServiceFactory.cs
│ │ │ ├── NoopEmbeddedWebServerService.cs
│ │ │ └── Platforms/ # 平台特定服务
│ │ │ ├── AndroidGlobalHotKeyService.cs
│ │ │ └── LinuxGlobalHotKeyService.cs
│ │ ├── Views/ # 视图
│ │ │ ├── MainView.axaml
│ │ │ ├── MainView.axaml.cs
│ │ │ ├── MainWindow.axaml
│ │ │ └── MainWindow.axaml.cs
│ │ ├── App.axaml # Avalonia 应用入口
│ │ ├── App.axaml.cs
│ │ ├── Program.cs # Avalonia 程序配置
│ │ ├── appsettings.json # 配置文件
│ │ ├── setup.iss # 安装脚本
│ │ ├── wwwroot/ # 前端静态资源
│ │ └── Hua.Todo.Avalonia.csproj # Avalonia 项目文件
│ │
│ ├── Hua.Todo.Host/ # 后端 API 项目
│ │ ├── Controllers/ # API 控制器
│ │ │ ├── TasksController.cs
│ │ │ ├── SettingsController.cs
│ │ │ └── SyncController.cs
│ │ ├── Models/ # 数据模型
│ │ │ ├── Task.cs
│ │ │ ├── TaskDto.cs
│ │ │ └── ApiResponse.cs
│ │ ├── Services/ # 业务服务
│ │ │ ├── ITaskService.cs
│ │ │ ├── TaskService.cs
│ │ │ ├── ISyncService.cs
│ │ │ └── SyncService.cs
│ │ ├── Data/ # 数据访问层
│ │ │ ├── TodoDbContext.cs
│ │ │ ├── Converters/ # 类型转换器
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Middleware/ # 中间件
│ │ │ └── ExceptionMiddleware.cs
│ │ ├── Extensions/ # 扩展方法
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Host.csproj # Host 项目文件
│ │
│ ├── Hua.Todo.Application/ # 应用层
│ │ ├── Data/ # 数据访问
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Converters/ # 类型转换器
│ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ └── Hua.Todo.Application.csproj # Application 项目文件
│ │
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/ # 实体类
│ │ │ ├── Task.cs
│ │ │ └── TaskPriority.cs
│ │ ├── Interfaces/ # 接口定义
│ │ │ ├── ITaskRepository.cs
│ │ │ └── IUnitOfWork.cs
│ │ ├── ValueObjects/ # 值对象
│ │ │ └── TaskTitle.cs
│ │ ├── Specifications/ # 规范模式
│ │ │ └── TaskSpecifications.cs
│ │ └── Hua.Todo.Core.csproj # Core 项目文件
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ │ └── favicon.svg # 网站图标
│ │ ├── src/ # 源代码
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts # HTTP 客户端配置
│ │ │ │ ├── tasks.ts # 任务相关 API
│ │ │ │ └── settings.ts # 设置相关 API
│ │ │ ├── assets/ # 资源文件
│ │ │ │ ├── images/
│ │ │ │ └── styles/
│ │ │ ├── components/ # Vue 组件
│ │ │ │ ├── TaskList.vue
│ │ │ │ ├── TaskItem.vue
│ │ │ │ ├── QuickEntry.vue
│ │ │ │ └── Settings.vue
│ │ │ ├── composables/ # 组合式函数
│ │ │ │ ├── useTasks.ts
│ │ │ │ └── useHotKey.ts
│ │ │ ├── stores/ # 状态管理 (Pinia)
│ │ │ │ ├── tasks.ts
│ │ │ │ └── settings.ts
│ │ │ ├── types/ # TypeScript 类型定义
│ │ │ │ ├── task.ts
│ │ │ │ └── api.ts
│ │ │ ├── utils/ # 工具函数
│ │ │ │ ├── date.ts
│ │ │ │ └── storage.ts
│ │ │ ├── App.vue # 根组件
│ │ │ └── main.ts # 应用入口
│ │ ├── package.json # 依赖配置
│ │ ├── vite.config.ts # Vite 配置
│ │ ├── tsconfig.json # TypeScript 配置
│ │ └── index.html # HTML 模板
│ │
│ └── Hua.Todo.Tests/ # 测试项目
│ ├── Unit/ # 单元测试
│ │ ├── Services/
│ │ │ └── TaskServiceTests.cs
│ │ └── Controllers/
│ │ └── TasksControllerTests.cs
│ ├── Integration/ # 集成测试
│ │ └── ApiIntegrationTests.cs
│ └── Hua.Todo.Tests.csproj
├── .gitignore # Git 忽略文件
├── Hua.Todo.sln # 解决方案文件
└── README.md # 项目说明文档
```
## 4. 模块设计
### 4.1 MAUI 主项目 (Hua.Todo.Maui)
**职责**:
- 应用程序入口和生命周期管理
- 平台特定功能封装
- WebView 容器管理
- 本地 HTTP 服务器启动
**调试与接口文档**
- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。
**关键组件**:
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
- `App.xaml.cs`: 应用程序主入口
- `WebViewContainer`: 封装 WebView 控件
- 平台特定服务: 快捷键、通知等
### 4.2 Avalonia 项目 (Hua.Todo.Avalonia)
**职责**:
- Linux 平台支持
- 桌面交互功能(托盘菜单、全局热键等)
- WebView 容器管理
- 本地 HTTP 服务器启动
**关键组件**:
- `Program.cs`: 配置 Avalonia 应用和依赖注入
- `App.axaml.cs`: 应用程序主入口
- `MainWindow.axaml.cs`: 主窗口管理
- `MainView.axaml.cs`: 主视图管理
- `GlobalHotKeyServiceFactory`: 全局热键服务工厂
- `EmbeddedWebServerServiceFactory`: 内嵌 Web 服务器服务工厂
- 平台特定服务: Linux 全局热键等
### 4.3 后端 API 项目 (Hua.Todo.Host)
**职责**:
- 提供 RESTful API 接口
- 业务逻辑处理
- 数据访问和持久化
- 本地 HTTP 服务器托管
**接口文档**
- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。
**关键组件**:
- `CloudSync`: 云同步 Minimal APIs`/auth``/tasks``/sync``/security`
- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API`/api/{service}/...`)对外暴露
- `Data`: 数据访问层和数据库上下文
- `Program.cs`: API 服务器配置和启动
### 4.4 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
- 实现领域驱动设计模式
**关键组件**:
- `Entities`: 领域实体
- `Interfaces`: 业务接口定义
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.5 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
- HTTP API 调用
- 状态管理
**关键组件**:
- `components`: Vue 组件
- `api`: API 调用封装
- `stores`: 状态管理
- `composables`: 组合式函数
## 5. HTTP API 设计
### 5.1 API 基础配置
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`
- **基础 URLMAUI 内嵌模式)**: `{HostUrl}/api``HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`
- **数据格式**: JSON
- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证)
- **跨域配置**: 允许本地跨域请求
### 5.2 API 端点设计
#### 任务管理 API
```
GET /api/task # 获取任务列表(默认:全部)
GET /api/task/active # 获取未完成任务
GET /api/task/completed # 获取已完成任务
GET /api/task/{id} # 获取单个任务
POST /api/task # 创建任务
PUT /api/task # 更新任务(通过 Body 内的 id 定位)
DELETE /api/task/{id} # 删除任务
PATCH /api/task/{id}/toggle # 切换完成状态
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
```
#### 云同步 APIHost 模式)
```
POST /auth/bootstrap # 初始化管理员(仅首次)
POST /auth/login # 登录
POST /auth/step-up # 二次验证(提升权限)
GET /tasks/ # 获取云端任务(只读)
POST /sync/ # 推送/拉取合并同步
GET /security/policy # 获取安全策略
PUT /security/policy # 更新安全策略
```
## 6. 数据库设计
### 6.1 数据库表结构
#### Tasks 表
```sql
CREATE TABLE Tasks (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Title TEXT NOT NULL,
Priority INTEGER NOT NULL DEFAULT 0,
IsCompleted INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
#### Settings 表
```sql
CREATE TABLE Settings (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
### 6.2 数据访问策略
- 使用 Entity Framework Core 进行数据访问
- 采用 Repository 模式封装数据访问
- 支持 LINQ 查询和异步操作
- 数据库迁移管理
## 7. 通信机制
### 7.1 HTTP 通信流程
1. **C# 后端启动**: MAUI 应用启动时启动本地 Kestrel 服务器
2. **Vue 前端加载**: WebView 加载 Vue 应用
3. **API 调用**: Vue 通过 Axios 调用本地 HTTP API
4. **数据处理**: C# 后端处理请求并返回 JSON 数据
5. **界面更新**: Vue 接收响应并更新界面
### 7.2 错误处理
- 统一的错误响应格式
- 异常中间件捕获和处理
- 前端错误提示和重试机制
## 8. 部署和打包
### 8.1 开发环境
- **后端调试**: 使用 Visual Studio 调试 MAUI 应用
- **前端调试**: 使用 Vite 开发服务器
- **热重载**: 支持前后端热重载
### 8.2 生产构建
- **前端构建**: `npm run build` 生成静态文件
- **后端打包**: MAUI 发布各平台应用
- **静态文件嵌入**: 将前端静态文件嵌入到 MAUI 应用中
### 8.3 平台特定配置
- **Windows**: WebView2 运行时要求
- **macOS**: 代码签名和公证
- **移动端**: 应用商店发布配置
- **Linux**: .NET MAUI 无官方 Linux 目标,需要引入独立桌面宿主(例如基于 WebKitGTK 的方案)并处理运行时依赖与打包格式
## 9. 性能优化
### 9.1 前端优化
- 组件懒加载
- 虚拟滚动(长列表)
- 图片懒加载
- 缓存策略
### 9.2 后端优化
- 数据库查询优化
- 响应缓存
- 异步处理
- 连接池管理
## 10. 安全考虑
### 10.1 本地安全
- 本地服务器仅监听 localhost
- 防止外部访问
- 数据加密存储(可选)
### 10.2 数据安全
- 数据库文件权限控制
- 定期备份机制
- 敏感数据保护
## 11. 测试策略
### 11.1 单元测试
- 核心业务逻辑测试
- 服务层测试
- 工具函数测试
### 11.2 集成测试
- API 集成测试
- 数据库集成测试
- 前后端集成测试
### 11.3 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
+51
View File
@@ -0,0 +1,51 @@
# 项目介绍
> 本文档面向普通用户,介绍 Hua.Todo 是什么、能做什么、支持哪些平台。
## 1. 什么是 Hua.Todo
Hua.Todo 是一个**跨平台待办事项管理工具**,帮助您记录和管理日常任务。
### 核心功能
| 功能 | 说明 |
|---|---|
| 待办管理 | 创建、编辑、删除、标记完成;支持父子任务层级 |
| 多平台 | Windows、macOS、Linux、Android、iOS 均可使用 |
| 云同步 | 登录后自动同步多端数据,随时随地查看 |
| 语音控制 | 通过语音指令快速操作待办项 |
| 关键词搜索 | 主界面搜索框按标题实时过滤 |
| 托盘常驻 | 最小化到系统托盘,全局热键快速唤起 |
### 界面一览
- **主界面**:任务列表 + 搜索框 + 同步按钮 + 快速创建入口
- **云同步设置**:配置服务端地址、登录/登出、安全策略查看
- **任务编辑**:标题、优先级、父子关系、完成状态
## 2. 支持的平台
| 平台 | 说明 |
|---|---|
| Windows | 安装包(`.exe`)或绿色版,需 WebView2 Runtime |
| Linux | `.tar.gz` 压缩包,需 WebKitGTK |
| macOS | 通过 MAUI 构建(开发中) |
| Android / iOS | 通过 MAUI 构建(开发中) |
## 3. 两种使用方式
### 方式一:单机使用(默认)
下载安装后直接使用,所有数据存储在本地。无需网络、无需注册。
### 方式二:启用云同步
配置云同步服务端地址并登录后,数据自动同步到服务端,可在多台设备间共享。
> 云同步需要额外部署 Hua.Todo.Host 服务端(可自行部署或使用第三方提供的服务)。
## 4. 下一步
- 如何安装 → [02-安装指南](./02-安装指南.md)
- 更新了什么 → [03-版本记录](./03-版本记录.md)
- 了解技术细节 → [05-技术栈与项目结构](./05-技术栈与项目结构.md)(开发者)
+108
View File
@@ -0,0 +1,108 @@
# 安装指南
> 本文档介绍如何安装和使用 Hua.Todo 客户端,以及如何部署云同步服务端。
---
## 1. Windows 安装
### 1.1 安装包(推荐)
1. 下载 `Hua.Todo_Setup_vX.Y.Z.exe`
2. 运行安装程序,按向导完成安装
3. 安装完成后会在桌面和开始菜单创建快捷方式
> 静默安装:`Hua.Todo_Setup_vX.Y.Z.exe /VERYSILENT /SUPPRESSMSGBOXES`
### 1.2 绿色版
1. 下载绿色版压缩包并解压
2. 确保系统已安装 [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/)
3. 运行 `Hua.Todo.Maui.exe`
---
## 2. Linux 安装
### 2.1 解压部署
```bash
tar -xzf hua.todo-{version}-linux-x64.tar.gz -C /opt/hua-todo
```
### 2.2 环境依赖
- 确保系统安装了 `libwebkit2gtk-4.0-37`
- 如果版本未自带 .NET Runtime,需安装 .NET 10 Runtime
### 2.3 启动
```bash
chmod +x /opt/hua-todo/Hua.Todo.Avalonia
/opt/hua-todo/Hua.Todo.Avalonia
```
---
## 3. 云同步服务端部署
如需使用云同步功能,需要部署 `Hua.Todo.Host` 服务端。
### 3.1 Docker 部署(推荐)
```bash
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
docker run -d \
--name hua-todo-server \
-p 5173:5173 \
-v /data/hua-todo/db:/app/data \
-e ASPNETCORE_ENVIRONMENT=Production \
hua-todo-server
```
### 3.2 直接部署
```bash
dotnet publish src/Hua.Todo.Host -c Release -o ./dist
# 将 ./dist 内容同步到服务器
# 可选:配置 Systemd 服务
```
---
## 4. 配置云同步
1. 打开 Hua.Todo → 点击"云同步设置"
2. 输入服务端地址(如 `http://your-server:5173`
3. 点击"保存并探测"确认可达
4. 输入用户名和密码登录
5. 登录成功后即可使用同步功能
---
## 5. 关键配置
### 5.1 数据位置
- **客户端**:数据默认存储在用户目录 `Hua.Todo/todo.db`
- **服务端**:数据存储在容器挂载卷或数据库连接字符串指定位置
### 5.2 端口
- 客户端本地端口默认 `5057`,可在 `appsettings.json` 中修改
- 服务端端口默认 `5173`
---
## 6. 常见问题
| 问题 | 解决方法 |
|---|---|
| Windows 客户端无法加载 UI | 安装 [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) |
| Linux 客户端无法启动 | 安装 `libwebkit2gtk-4.0-37`;执行 `chmod +x` |
| 同步连接失败 | 确认服务端可达(访问 `/swagger`);确认地址格式含 `http://``https://` |
| 端口被占用 | 修改 `appsettings.json` 中的 `WebServer.HostUrl` |
---
> 开发者如需从源码构建,见 [06-开发环境与构建](./06-开发环境与构建.md)。
-46
View File
@@ -1,46 +0,0 @@
# 技术栈与模块说明
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 13 (基于 .NET 10)
- **框架**.NET 10 (Preview/Early Adopter)
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API (支持动态 API 生成)
- **数据访问**Entity Framework Core 10.0
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript 5+
- **框架**Vue.js 3
- **构建工具**Vite 5+
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
## 🎯 核心模块说明
### Hua.Todo.Core
领域实体层,定义核心实体(TaskEntity)、安全策略实体(SecurityPolicyEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
### Hua.Todo.Application
应用层实现,包含:
- **业务逻辑**TaskService 实现。
- **动态 API**:基于 Middleware 的动态 API 生成逻辑。
- **云同步 (CloudSync)**:包含 Auth 验证、同步服务、安全策略管理等。
- **数据访问**EF Core 数据库上下文(TodoDbContext)与迁移。
### Hua.Todo.Host
后端 API 宿主,作为独立 Server 运行时提供运行环境和配置。
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
### Hua.Todo.Maui
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
### Hua.Todo.Avalonia
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
@@ -1,17 +1,24 @@
# 版本更新历史
## 🔄 版本更新
## 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
- v1.2.0Linux 支持与增强功能
- v1.3.0(开发中):MCP 服务与语音控制
### v1.3.0 (2026-06-16)
- **MCP 服务**:新增 MCP 协议端点(`/mcp`),DynamicMcpToolExtensions 自动扫描 IDynamicApiService 接口生成 MCP 工具;当前覆盖 ITaskService9 个)+ IVoiceService4 个)= 13 个 MCP 工具
- **语音控制核心**:新增 Voice 模块(Core 接口、LLM 客户端、双策略意图解析器、指令执行器、AiBreakdownService);26 个单元测试通过
- **文档重组**`docs/manual/` 目录按普通用户/开发者分离重组,新增双入口导航
### v1.2.8 (2026-06-14)
- **文档**:新增 [任务同步规则.md](./02-任务同步规则.md),汇总 Todo 待办项云同步的架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等完整规则。
- **文档**:新增 [同步规则](./08-云同步规则.md),汇总 Todo 待办项云同步的架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等完整规则。
### v1.2.8 (2026-04-13)
@@ -25,30 +32,29 @@
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用命中即显示(含上下文)策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增云同步设置弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用"命中即显示(含上下文)"策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增"云同步设置"弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
- **MAUIWindows)内嵌 API 文档**Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
- **Android 启动稳定性修复**:在 AndroidManifest 中移除 `androidx.startup.InitializationProvider` 自动初始化入口,规避 `androidx.lifecycle.ProcessLifecycleInitializer` 缺失导致的启动崩溃(`NoClassDefFoundError`)。
- **MAUI Android 调试配置修复**:在 `Hua.Todo.Maui.csproj` 中显式启用 `AndroidApplication`,并将调试架构配置从 `AndroidSupportedAbis` 切换为 `RuntimeIdentifiers=android-x64`,减少 Visual Studio 启动 Android 调试时的项目识别与模拟器架构问题。
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免接口缺失导致联调困难。
- **SQLite DateTime 兼容修复**:新增 `LenientUtcDateTimeStringConverter`,本地数据库中若存在历史遗留的 DateTime ticks/时间戳字符串脏数据,读取时将被兼容解析,避免 `/api/task` 等查询因单条坏数据整体失败。
- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作后端专用路径排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免"接口缺失"导致联调困难。
- **SQLite DateTime 兼容修复**:新增 `LenientUtcDateTimeStringConverter`,本地数据库中若存在历史遗留的 DateTime "ticks/时间戳字符串"脏数据,读取时将被兼容解析,避免 `/api/task` 等查询因单条坏数据整体失败。
- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作"后端专用路径"排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。
- **MAUI 多平台构建开关**:在 Windows 开发机上默认仅构建 Android + Windows 目标,避免 iOS/MacCatalyst 目标在非 macOS 环境触发运行时包缺失(NETSDK1082);在 macOS 上仍会包含 iOS/MacCatalyst 目标。
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`
- **Windows 发布打包修复**:Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。
- **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为未安装;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为"未安装";现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
- **文档与部署指南**:新增 `docs/manual/05-部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接
- **用户文档完善**:在规划中新增了 `docs/manual/08-新手指南.md``docs/manual/09-用户指南.md`
27→
28→### v1.1.1 (2026-04-06)
- **文档与部署指南**:新增部署文档,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项。
### v1.1.1 (2026-04-06)
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
- **项目结构说明校准**:修正 README.md 和技术文档中对 `Hua.Todo.Host``Hua.Todo.Application` 等模块的路径与职责描述。
- **端口配置校准**:修正文档中关于前端与后端 API 的端口说明(5173/5174)。
- **PRD 校准**:移除 v1.2.0 PRD 中本地迭代不支持表述与数据迁移(导入/导出)小节。
- **PRD 校准**:移除 v1.2.0 PRD 中云同步需求。
- **PRD 校准**:移除 v1.2.0 PRD 中"本地迭代不支持"表述与"数据迁移(导入/导出)"小节。
- **PRD 校准**:移除 v1.2.0 PRD 中"云同步"需求。
### v1.1.0 更新内容
@@ -59,11 +65,6 @@
- 使用 SQLite 作为本地数据库
- 实现子任务支持
### v1.2.0 规划内容 (即将推出)
### v1.0.0 初始版本
- **Linux 官方支持**:正式适配 Linux 平台
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpakmanifest/desktop entry/AppStream)基础结构。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
- 初始 WPF 版本
@@ -1,6 +1,6 @@
# 其他信息
## 🤝 贡献指南
## 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
@@ -8,11 +8,11 @@
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
## 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](LICENSE) (英文) 或 [LICENSE.zh-CN](LICENSE.zh-CN) (中文) 文件了解详情
## 📞 联系方式
## 联系方式
- 项目作者:ShaoHua
- 项目地址:https://git.we965.cn/Tools/Hua.Todo
+127
View File
@@ -0,0 +1,127 @@
# 技术栈与项目结构
> 本文档面向开发者,介绍 Hua.Todo 的技术选型、项目划分、模块职责与依赖关系。
## 1. 技术栈
### 1.1 后端
| 技术 | 版本/说明 |
|---|---|
| 开发语言 | C# 13 |
| 框架 | .NET 10 |
| UI 框架 | MAUI(移动端/部分桌面)+ Avalonia(桌面端) |
| Web 服务器 | KestrelASP.NET Core 内置) |
| API 框架 | ASP.NET Core Web API(含动态 API 生成) |
| ORM | Entity Framework Core 10.0 |
| 数据库 | SQLite(本地存储) |
| 依赖注入 | Microsoft.Extensions.DependencyInjection |
| 日志 | Serilog |
### 1.2 前端
| 技术 | 版本/说明 |
|---|---|
| 开发语言 | TypeScript 5+ |
| 框架 | Vue.js 3 |
| 构建工具 | Vite 5+ |
| HTTP 客户端 | Axios |
| 状态管理 | Pinia |
| UI 组件库 | Element Plus / Vant(移动端) |
| CSS 预处理器 | SCSS |
## 2. 项目结构
```
Hua.Todo/
├── src/
│ ├── Hua.Todo.Core/ # 领域实体、枚举、仓储接口
│ ├── Hua.Todo.Application/ # 业务逻辑、EF Core、动态 API、云同步
│ ├── Hua.Todo.Host/ # 独立服务端宿主(ASP.NET)
│ ├── Hua.Todo.Maui/ # MAUI 客户端(Windows/macOS/Android/iOS
│ ├── Hua.Todo.Avalonia/ # Avalonia 客户端(Linux/Windows 桌面)
│ ├── Hua.Todo.Web/ # Vue 3 前端(Vite
│ └── test/ # 测试项目
│ ├── Hua.Todo.Host.Tests/ # Host 服务端测试
│ ├── Hua.Todo.Maui.Tests/ # MAUI 客户端测试
│ └── Hua.Todo.Avalonia.Tests/ # Avalonia 客户端测试
├── docs/
│ ├── manual/ # 项目手册
│ ├── project/ # 产品需求文档与研发工单
│ └── AI沟通记录/ # AI 对话记录
└── .trae/ # 智能体规则与记忆
```
## 3. 核心模块说明
### 3.1 Hua.Todo.Core
领域实体层,定义核心实体与接口:
- **实体**`TaskEntity``UserEntity``SecurityPolicyEntity``AuditLogEntity`
- **枚举**`TaskPriority`
- **仓储接口**`ITaskRepository`
- **语音控制接口**`IVoiceInputService``IVoiceOutputService``IVoiceIntentParser`
### 3.2 Hua.Todo.Application
应用层实现,所有业务逻辑的集中地:
- **TaskService**:待办项 CRUD 业务逻辑
- **动态 API**`DynamicApi/`):基于接口自动生成 RESTful API
- **云同步**`CloudSync/`):认证服务、同步服务、安全策略管理
- **语音控制**`Voice/`):LLM 客户端、双策略意图解析器、指令执行器
- **MCP 服务**`Mcp/`):自动扫描并暴露 MCP 工具
- **数据访问**`TodoDbContext` + EF Core 迁移
### 3.3 Hua.Todo.Host
独立服务端宿主,同时注册业务服务与云同步端点:
- `AddApplicationServices()`:待办项 CRUD + 动态 API
- `AddCloudSyncServer()`:认证、同步、安全策略端点
- `AddMcpServerServices()`MCP 协议端点(`/mcp`
- `AddVoiceServices()`:语音控制端点
### 3.4 Hua.Todo.Maui
跨平台客户端,内嵌 Kestrel WebServer + WebView 承载前端:
- 仅注册 `AddApplicationServices()`,不暴露云同步端点
- 平台特定服务(快捷键、通知等)
### 3.5 Hua.Todo.Avalonia
桌面客户端(Avalonia + WebView),提供 Linux/Windows 桌面形态:
- 同样通过嵌入式 WebServer + WebView 承载前端
- 托盘菜单、全局热键等桌面交互功能
### 3.6 Hua.Todo.Web
Vue 3 前端项目,同一份构建产物被多个宿主以静态资源形式承载:
- **组件**TaskList、TaskItem、TaskEditDialog、CloudSyncSettings 等
- **状态管理**Pinia stores
- **API 层**`api/tasks.ts``api/cloudSync.ts``api/mcp.ts`
## 4. 依赖方向
```
Hua.Todo.Core
Hua.Todo.Application
├── Hua.Todo.Host (服务端:全部能力)
├── Hua.Todo.Maui (客户端:仅业务服务)
└── Hua.Todo.Avalonia (客户端:仅业务服务)
Hua.Todo.Web (无 .NET 依赖;通过 HTTP 调用上述任一宿主)
```
- **禁止反向依赖**Core 不得引用 ApplicationApplication 不得引用任何宿主项目
- **客户端不暴露云同步端点**MAUI / Avalonia 只调用 `AddApplicationServices()`
## 5. 关键扩展点
| 扩展点 | 位置 |
|---|---|
| DI 注册总入口 | `Hua.Todo.Application/ServiceCollectionExtensions.cs` |
| 云同步 DI | `Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs` |
| 动态 API 中间件 | `Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs` |
| MCP 工具注册 | `Hua.Todo.Application/Mcp/DynamicMcpToolExtensions.cs` |
| 语音意图解析 | `Hua.Todo.Application/Voice/HybridVoiceIntentParser.cs` |
| 嵌入式 WebServer | `Hua.Todo.{Maui,Avalonia}/Services/EmbeddedWebServerService.cs` |
## 6. 下一步
- 搭建开发环境 → [06-开发环境与构建](./06-开发环境与构建.md)
- 深入架构设计 → [07-技术架构设计](./07-技术架构设计.md)
-128
View File
@@ -1,128 +0,0 @@
# Hua.Todo 构建与部署手册
本文档提供 Hua.Todo 项目的版本构建指南与多平台部署流程,侧重于如何产出可分发的版本并将其安装或部署到目标机器。
## 1. 🏗️ 版本构建流程 (Release Build)
在进行任何部署之前,必须先通过自动化脚本构建出 Release 版本的产物。
### 1.1 前提要求
- **环境检查**:已安装 .NET 10 SDK、Node.js 18+ 以及 Inno Setup 6(仅 Windows 打包需要)。
- **脚本入口**:位于根目录的 `publish.ps1` 系列脚本。
### 1.2 执行构建
根据目标平台执行对应的构建命令:
- **全平台一键构建**
```powershell
.\publish.ps1 -Windows -Linux
```
- **Windows 版本构建**
```powershell
.\publish-windows.ps1
```
- **Linux 版本构建**
```powershell
.\publish-linux.ps1 -RuntimeIdentifier linux-x64 -SelfContained
```
### 1.3 产物输出位置
所有构建产物将按平台归类在根目录的 `artifacts/` 文件夹下:
- **Windows**: `artifacts\windows\win-x64\installer\` (安装包) 与 `publish\` (解压即用版)
- **Linux**: `artifacts\linux\linux-x64\` (`.tar.gz` 压缩包)
***
## 2. 🪟 Windows 客户端部署
### 2.1 安装包分发 (Recommended)
1. **分发文件**:将 `hua.todo-{version}-win-x64-setup.exe` 提供给终端用户。
2. **安装流程**:用户运行安装程序,程序将自动:
- 安装到 `%ProgramFiles%\Hua.Todo`(或用户自定义路径)。
- 创建桌面与开始菜单快捷方式。
- 写入注册表以支持卸载。
3. **静默安装**:支持 Inno Setup 标准静默参数 `/VERYSILENT /SUPPRESSMSGBOXES`
### 2.2 绿色版部署
1. **分发文件**:将 `artifacts\windows\win-x64\publish\` 文件夹整体打包。
2. **运行要求**:目标机器需安装 **WebView2 Runtime**
3. **启动程序**:运行 `Hua.Todo.Maui.exe`
***
## 3. 🐧 Linux 客户端部署
目前提供基于 Avalonia 的 Linux 交付版本,采用 `.tar.gz` 压缩包形式。
### 3.1 解压部署
1. **解压**
```bash
tar -xzf hua.todo-{version}-linux-x64.tar.gz -C /opt/hua-todo
```
2. **运行环境**
- 确保系统安装了 `libwebkit2gtk-4.0-37`。
- 如果构建时未开启 `-SelfContained`,则需安装 .NET 10 Runtime。
3. **权限设置**
```bash
chmod +x /opt/hua-todo/Hua.Todo.Avalonia
```
4. **启动**:直接运行 `/opt/hua-todo/Hua.Todo.Avalonia`。
***
## 4. ☁️ 云同步服务端部署 (Server)
`Hua.Todo.Host` 可作为中心服务端部署,用于任务的云端同步。
### 4.1 Docker 部署 (Recommended)
1. **上传代码**或将 `src/Hua.Todo.Host/Dockerfile` 复制到服务器。
2. **构建与启动**
```bash
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
docker run -d \
--name hua-todo-server \
-p 5173:5173 \
-v /data/hua-todo/db:/app/data \
-e ASPNETCORE_ENVIRONMENT=Production \
hua-todo-server
```
### 4.2 直接部署 (Binary)
1. **构建 Host**`dotnet publish src/Hua.Todo.Host -c Release -o ./dist`。
2. **同步产物**:将 `./dist` 内容同步到服务器。
3. **配置 Systemd 服务**(可选):
创建一个 `hua-todo.service` 文件,指向可执行程序并设置工作目录。
***
## 5. ⚙️ 关键配置校准
### 5.1 数据持久化
- **SQLite 路径**:客户端数据默认存储在用户目录下的 `Hua.Todo/todo.db`;服务端数据存储在容器挂载卷或指定 `appsettings.json` 的连接字符串中。
- **备份建议**:定期备份 `todo.db` 文件。
### 5.2 端口与访问
- **客户端本地端口**:默认 `5057`。如果被占用,可在 `appsettings.json` 中修改 `WebServer.HostUrl`。
- **服务端访问**:确保服务端防火墙已开放对应的 API 端口(默认 5173)。
***
## 6. 🛠️ 常见问题排查
- **客户端无法加载 UI**:检查目标机器是否安装了 WebView2 RuntimeWindows)或 WebKitGTKLinux)。
- **同步连接失败**
1. 确认服务端 API 是否可达(访问 `/swagger` 验证)。
2. 确认客户端配置的服务端地址格式(需包含 `http://` 或 `https://`)。
- **权限问题**:在 Linux 下运行前,务必执行 `chmod +x` 给主程序执行权限。
+100
View File
@@ -0,0 +1,100 @@
# 开发环境与构建
> 本文档面向开发者,介绍如何搭建开发环境、从源码构建和部署。
## 1. 开发环境搭建
### 1.1 前提要求
- .NET 10 SDK
- Node.js 18+
- Visual Studio 2022+(推荐)或 VS Code
- Inno Setup 6(仅 Windows 打包需要)
### 1.2 克隆与安装
```bash
git clone https://git.we965.cn/Tools/Hua.Todo.git
cd Hua.Todo
```
前端依赖安装:
```bash
cd src/Hua.Todo.Web
npm install
```
### 1.3 启动开发环境
**方式一:Windows 三件套(推荐)**
```powershell
# 终端 1:启动 Host(API 服务端)
.\start-host.ps1
# 终端 2:启动前端开发服务器
.\start-dev.ps1
```
**方式二:MAUI 嵌入模式**
在 Visual Studio 中直接调试运行 `Hua.Todo.Maui` 项目,MAUI 会自动启动内嵌 WebServer 并加载前端。
## 2. 版本构建 (Release Build)
### 2.1 脚本入口
位于根目录的 `publish.ps1` 系列脚本:
```powershell
# 全平台一键构建
.\publish.ps1 -Windows -Linux
# Windows 单独构建
.\publish-windows.ps1
# Linux 单独构建
.\publish-linux.ps1 -RuntimeIdentifier linux-x64 -SelfContained
```
### 2.2 产物输出位置
| 平台 | 路径 |
|---|---|
| Windows 安装包 | `artifacts\windows\win-x64\installer\` |
| Windows 绿色版 | `artifacts\windows\win-x64\publish\` |
| Linux | `artifacts\linux\linux-x64\``.tar.gz` |
## 3. 服务端部署
### 3.1 Docker 部署(推荐)
```bash
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
docker run -d \
--name hua-todo-server \
-p 5173:5173 \
-v /data/hua-todo/db:/app/data \
-e ASPNETCORE_ENVIRONMENT=Production \
hua-todo-server
```
### 3.2 直接部署
```bash
dotnet publish src/Hua.Todo.Host -c Release -o ./dist
# 将 ./dist 同步到服务器,可选配置 Systemd 服务
```
## 4. 数据库
- **开发/测试**SQLite,路径 `src/Hua.Todo.Host/Hua.Todo.db`
- **生产**:SQLite(默认)或切换其他 EF Core 支持的数据库
- **迁移**:嵌入式宿主启动时自动执行 `db.Database.Migrate()`
## 5. 下一步
- 了解架构细节 → [07-技术架构设计](./07-技术架构设计.md)
- 了解云同步 → [08-云同步规则](./08-云同步规则.md)
- 编码规范 → [09-代码规范](./09-代码规范.md)
+390
View File
@@ -0,0 +1,390 @@
# 技术架构设计
> 本文档面向开发者,描述 Hua.Todo 的详细技术架构,包括目录结构、模块设计、API 端点、数据库设计与通信机制。
## 1. 项目目录结构
```
Hua.Todo/
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ │ ├── 00-目录与导读.md
│ │ ├── 01-项目介绍.md
│ │ ├── 02-安装指南.md
│ │ ├── 03-版本记录.md
│ │ ├── 04-其他信息.md
│ │ ├── 05-技术栈与项目结构.md
│ │ ├── 06-开发环境与构建.md
│ │ ├── 07-技术架构设计.md(本文件)
│ │ ├── 08-云同步规则.md
│ │ ├── 09-代码规范.md
│ │ └── 10-MCP服务集成.md
│ └── project/ # 项目进度/需求文档
├── src/ # 源代码目录
│ ├── Hua.Todo.Maui/ # MAUI 主项目(跨平台入口)
│ │ ├── Platforms/ # 平台特定代码
│ │ │ ├── Windows/ # Windows 平台代码
│ │ │ │ ├── App.xaml
│ │ │ │ └── Services/
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── MacCatalyst/ # macOS 平台代码
│ │ │ ├── Android/ # Android 平台代码
│ │ │ └── iOS/ # iOS 平台代码
│ │ ├── Resources/ # 资源文件
│ │ ├── Controls/ # 自定义控件
│ │ │ └── WebViewContainer.xaml
│ │ ├── Services/ # 服务层
│ │ │ ├── IHotKeyService.cs
│ │ │ ├── IPlatformService.cs
│ │ │ └── AppLifecycleService.cs
│ │ ├── App.xaml / App.xaml.cs
│ │ ├── MauiProgram.cs
│ │ └── Hua.Todo.Maui.csproj
│ │
│ ├── Hua.Todo.Avalonia/ # Avalonia 项目(Linux 支持)
│ │ ├── Services/
│ │ │ ├── EmbeddedWebServerServiceFactory.cs
│ │ │ ├── GlobalHotKeyServiceFactory.cs
│ │ │ └── Platforms/
│ │ ├── Views/
│ │ │ ├── MainView.axaml / MainView.axaml.cs
│ │ │ └── MainWindow.axaml / MainWindow.axaml.cs
│ │ ├── App.axaml / App.axaml.cs
│ │ ├── Program.cs
│ │ ├── appsettings.json
│ │ ├── setup.iss
│ │ ├── wwwroot/
│ │ └── Hua.Todo.Avalonia.csproj
│ │
│ ├── Hua.Todo.Host/ # 独立服务端
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json
│ │ └── Hua.Todo.Host.csproj
│ │
│ ├── Hua.Todo.Application/ # 应用层
│ │ ├── Data/
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Migrations/
│ │ ├── DynamicApi/
│ │ ├── CloudSync/
│ │ ├── Voice/
│ │ ├── Mcp/
│ │ └── Hua.Todo.Application.csproj
│ │
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/
│ │ │ ├── TaskEntity.cs
│ │ │ ├── UserEntity.cs
│ │ │ ├── SecurityPolicyEntity.cs
│ │ │ └── AuditLogEntity.cs
│ │ ├── Interfaces/
│ │ └── Hua.Todo.Core.csproj
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── src/
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts
│ │ │ │ ├── tasks.ts
│ │ │ │ ├── cloudSync.ts
│ │ │ │ └── mcp.ts
│ │ │ ├── components/ # Vue 组件
│ │ │ │ ├── TaskList.vue
│ │ │ │ ├── TaskItem.vue
│ │ │ │ └── TaskEditDialog.vue
│ │ │ ├── composables/ # 组合式函数
│ │ │ ├── stores/ # 状态管理 (Pinia)
│ │ │ ├── types/ # TypeScript 类型定义
│ │ │ ├── utils/ # 工具函数
│ │ │ ├── App.vue
│ │ │ └── main.ts
│ │ ├── package.json
│ │ ├── vite.config.ts
│ │ ├── tsconfig.json
│ │ └── index.html
│ │
│ └── test/ # 测试项目
│ ├── Hua.Todo.Host.Tests/ # Host 服务端测试
│ ├── Hua.Todo.Maui.Tests/ # MAUI 客户端测试
│ └── Hua.Todo.Avalonia.Tests/ # Avalonia 客户端测试
├── publish.ps1 / publish-windows.ps1 / publish-linux.ps1
├── Directory.Build.props
├── Hua.Todo.sln
└── README.md
```
## 2. 模块设计
### 2.1 Hua.Todo.Maui(跨平台客户端)
**职责**
- 应用程序入口和生命周期管理
- 平台特定功能封装
- WebView 容器管理
- 本地 HTTP 服务器启动
**关键组件**
- `MauiProgram.cs`:配置 MAUI 应用和依赖注入
- `App.xaml.cs`:应用程序主入口
- `WebViewContainer`:封装 WebView 控件
- 平台特定服务:快捷键、通知等
### 2.2 Hua.Todo.Avalonia(桌面客户端)
**职责**
- Linux 平台支持
- 桌面交互功能(托盘菜单、全局热键等)
- WebView 容器管理
- 本地 HTTP 服务器启动
**关键组件**
- `Program.cs`:配置 Avalonia 应用和依赖注入
- `App.axaml.cs`:应用程序主入口
- `MainWindow.axaml.cs`:主窗口管理
- `GlobalHotKeyServiceFactory`:全局热键服务工厂
- `EmbeddedWebServerServiceFactory`:内嵌 Web 服务器服务工厂
### 2.3 Hua.Todo.Host(独立服务端)
**职责**
- 提供 RESTful API 接口
- 云同步端点(认证、同步、安全策略)
- MCP 协议端点
- 语音控制端点
**接口文档**
- 开发环境下提供 Swagger UI`http://localhost:5173/swagger`
**关键组件**
- `CloudSync`:云同步 Minimal APIs`/auth``/tasks``/sync``/security`
- `DynamicApiMiddleware`:业务接口通过 Dynamic API`/api/{service}/...`)对外暴露
- `MCP`MCP 协议端点(`/mcp`
- `Program.cs`API 服务器配置和启动
### 2.4 Hua.Todo.Core(核心业务层)
**职责**
- 定义领域模型和业务规则
- 提供核心业务接口
- 实现领域驱动设计模式
**关键组件**
- `Entities`:领域实体(TaskEntity、UserEntity、SecurityPolicyEntity、AuditLogEntity
- `Interfaces`:业务接口定义
- `Voice`:语音控制相关接口与枚举
### 2.5 Hua.Todo.Application(应用层)
**职责**
- 业务逻辑实现
- EF Core 数据访问与迁移
- 动态 API 生成
- 云同步服务
- 语音控制服务
- MCP 工具注册
### 2.6 Hua.Todo.Web(前端)
**职责**
- 用户界面展示
- 用户交互处理
- HTTP API 调用
- 状态管理
## 3. HTTP API 设计
### 3.1 API 基础配置
| 模式 | 基础 URL |
|---|---|
| 开发 / Host 模式 | `http://localhost:5173/api` |
| MAUI 内嵌模式 | `{HostUrl}/api`(默认 `http://localhost:5057/api` |
- 数据格式:JSON
- 认证方式:云同步端点需要 Bearer Token,本地端点无需认证
### 3.2 任务管理 APIDynamic API
```
GET /api/task # 获取任务列表
GET /api/task/active # 获取未完成任务
GET /api/task/completed # 获取已完成任务
GET /api/task/{id} # 获取单个任务
POST /api/task # 创建任务
PUT /api/task # 更新任务
DELETE /api/task/{id} # 删除任务
PATCH /api/task/{id}/toggle # 切换完成状态
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
```
### 3.3 云同步 APIHost 模式)
```
POST /auth/bootstrap # 初始化管理员(仅首次)
POST /auth/login # 登录
POST /auth/step-up # 二次验证
GET /tasks/ # 获取云端任务
POST /sync/ # 推送/拉取合并同步
GET /security/policy # 获取安全策略
PUT /security/policy # 更新安全策略
```
### 3.4 MCP 端点
```
POST /mcp # MCP JSON-RPC 2.0 端点
```
## 4. 数据库设计
### 4.1 核心表结构
#### Tasks 表
```sql
CREATE TABLE T_Tasks (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
UserId TEXT NOT NULL,
Title TEXT NOT NULL,
Priority INTEGER NOT NULL DEFAULT 0,
IsCompleted INTEGER NOT NULL DEFAULT 0,
ParentTaskId INTEGER,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
#### Users 表
```sql
CREATE TABLE T_Users (
Id TEXT PRIMARY KEY,
UserName TEXT NOT NULL UNIQUE,
PasswordHash TEXT NOT NULL,
PasswordSalt TEXT NOT NULL,
Role TEXT NOT NULL,
MustChangePassword INTEGER NOT NULL DEFAULT 0
);
```
#### SecurityPolicies 表
```sql
CREATE TABLE T_SecurityPolicies (
Id TEXT PRIMARY KEY,
UserId TEXT NOT NULL,
AllowPersist INTEGER NOT NULL DEFAULT 1,
AllowSync INTEGER NOT NULL DEFAULT 1,
SecondFactorExpiryMinutes INTEGER DEFAULT 0,
IsTrustedDeviceOnly INTEGER NOT NULL DEFAULT 0
);
```
#### AuditLogs 表
```sql
CREATE TABLE T_AuditLogs (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
UserId TEXT,
Action TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Details TEXT
);
```
### 4.2 数据访问策略
- 使用 Entity Framework Core 进行数据访问
- 采用 Repository 模式封装数据访问
- 支持 LINQ 查询和异步操作
- EF Core 迁移管理(见 `Hua.Todo.Application/Migrations/`
- 表名遵循 ABP 规范:`T_{实体名}s`
## 5. 通信机制
### 5.1 HTTP 通信流程
1. **C# 后端启动**MAUI/Avalonia 应用启动时启动本地 Kestrel 服务器
2. **Vue 前端加载**WebView 加载 Vue 应用
3. **API 调用**Vue 通过 Axios 调用本地 HTTP API
4. **数据处理**:C# 后端处理请求并返回 JSON 数据
5. **界面更新**Vue 接收响应并更新界面
### 5.2 错误处理
- 统一的错误响应格式
- 异常中间件捕获和处理
- 前端错误提示和重试机制
### 5.3 WebView 注入
前端通过 `window` 对象接收宿主注入的信息:
- `window.__API_BASE_URL__`API 基础路径
- `window.mauiInterop`MAUI 平台互操作对象
## 6. 部署和打包
### 6.1 开发环境
- **后端调试**:使用 Visual Studio 调试 MAUI/Avalonia 应用
- **前端调试**:使用 Vite 开发服务器
- **热重载**:支持前后端热重载
### 6.2 生产构建
- **前端构建**`npm run build` 生成静态文件
- **后端打包**:各平台发布脚本产出可分发包
- **静态文件嵌入**:将前端静态文件嵌入到桌面应用中
详细构建流程见 [06-开发环境与构建](./06-开发环境与构建.md)。
### 6.3 平台特定配置
- **Windows**WebView2 运行时要求
- **macOS**:代码签名和公证
- **移动端**:应用商店发布配置
- **Linux**:需要 GTK + WebKitGTK 运行时依赖
## 7. 性能优化
### 7.1 前端优化
- 组件懒加载
- 虚拟滚动(长列表)
- 图片懒加载
- 缓存策略
### 7.2 后端优化
- 数据库查询优化(含索引策略)
- 响应缓存
- 异步处理
- SQLite WAL 模式(嵌入式宿主自动开启)
## 8. 安全考虑
### 8.1 本地安全
- 本地服务器仅监听 localhost
- 防止外部访问
- 数据加密存储(可选)
### 8.2 数据安全
- 数据库文件权限控制
- 定期备份机制
- 敏感数据保护
### 8.3 云同步安全
详见 [08-云同步规则](./08-云同步规则.md)。
## 9. 测试策略
### 9.1 单元测试
- 核心业务逻辑测试
- 服务层测试
- 工具函数测试
### 9.2 集成测试
- API 集成测试
- 数据库集成测试
- 前后端集成测试
### 9.3 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
-389
View File
@@ -1,389 +0,0 @@
# MCP 服务接口文档
> Hua.Todo MCP Server 接口规范,供外部系统(AI 客户端、第三方集成)通过 MCP 协议接入 Hua.Todo 待办项管理能力。
## 1. 概述
Hua.Todo 提供了基于 **Model Context Protocol (MCP)** 的服务端,允许外部 MCP 客户端通过 Streamable HTTP 传输协议发现并调用待办项管理工具。
### 1.1 协议与传输
| 项目 | 说明 |
|---|---|
| 协议版本 | MCP 2025-03-26Streamable HTTP |
| 传输方式 | Streamable HTTP(无状态模式) |
| 内容格式 | JSON-RPC 2.0 |
| 端点路径 | `/mcp` |
| 认证 | 暂无(本地模式);生产环境建议通过反向代理添加认证 |
### 1.2 服务端信息
```json
{
"name": "Hua.Todo MCP Server",
"version": "1.0.0"
}
```
### 1.3 连接地址
| 运行模式 | 默认地址 |
|---|---|
| Hua.Todo.Host(独立服务端) | `http://localhost:5173/mcp` |
| MAUI / Avalonia(嵌入式) | `http://localhost:5057/mcp` |
> 实际端口以部署配置为准。
---
## 2. 接入方式
### 2.1 MCP 客户端配置示例
**Claude Desktop / Cursor / 其他 MCP 客户端** 配置文件(`mcp_servers.json` 或等效):
```json
{
"mcpServers": {
"hua-todo": {
"url": "http://localhost:5173/mcp",
"transport": "streamable-http"
}
}
}
```
### 2.2 手动调用示例(curl
MCP 协议基于 JSON-RPC 2.0,可通过 HTTP POST 手动调用:
#### 初始化连接
```bash
curl -X POST http://localhost:5173/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "my-client", "version": "1.0.0" }
}
}'
```
#### 列出可用工具
```bash
curl -X POST http://localhost:5173/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}'
```
#### 调用工具
```bash
curl -X POST http://localhost:5173/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "CreateTodo",
"arguments": {
"title": "完成项目报告",
"priority": "High"
}
}
}'
```
---
## 3. 工具清单
### 3.1 查询类工具
#### ListAllTodos
获取所有待办项列表(含已完成和未完成)。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| 无 | - | - | - |
**返回示例**
```
所有待办项(共 3 项):
[1] 完成项目报告 | 优先级:High | 进行中
[2] 购买办公用品 | 优先级:Medium | 已完成
[3] 整理会议纪要 | 优先级:Low | 进行中
```
---
#### ListActiveTodos
获取未完成的待办项列表。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| 无 | - | - | - |
**返回示例**
```
未完成待办项(共 2 项):
[1] 完成项目报告 | 优先级:High | 进行中
[3] 整理会议纪要 | 优先级:Low | 进行中
```
---
#### ListCompletedTodos
获取已完成的待办项列表。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| 无 | - | - | - |
---
#### GetTodoById
根据 ID 获取单个待办项详情(含子任务)。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `int` | 是 | 待办项 ID |
**返回示例**
```json
{
"id": 1,
"title": "完成项目报告",
"priority": "High",
"isCompleted": false,
"createdAt": "2026-06-16T08:30:00Z",
"updatedAt": "2026-06-16T08:30:00Z",
"parentTaskId": null,
"subTasks": [
{
"id": 4,
"title": "收集数据",
"priority": "Medium",
"isCompleted": true,
"createdAt": "2026-06-16T08:35:00Z",
"updatedAt": "2026-06-16T10:00:00Z",
"parentTaskId": 1,
"subTasks": []
}
]
}
```
**未找到时**:返回文本 `"未找到 ID 为 {id} 的待办项"`
---
#### ListSubTodos
获取指定父待办项下的所有子待办项。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `parentTaskId` | `int` | 是 | 父待办项 ID |
---
### 3.2 写入类工具
#### CreateTodo
创建新的待办项。
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `title` | `string` | 是 | - | 待办项标题 |
| `priority` | `string` | 否 | `"Medium"` | 优先级:`Low` / `Medium` / `High` |
| `parentTaskId` | `int` | 否 | `null` | 父待办项 ID(创建子任务时传入) |
**返回示例**
```
已创建待办项:
{
"id": 5,
"title": "完成项目报告",
"priority": "High",
"isCompleted": false,
"createdAt": "2026-06-16T10:00:00Z",
"updatedAt": "2026-06-16T10:00:00Z",
"parentTaskId": null,
"subTasks": []
}
```
**优先级无效时**:返回文本 `"无效的优先级 'xxx',有效值为:Low、Medium、High"`
---
#### UpdateTodo
更新已有待办项的标题或优先级。
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `id` | `int` | 是 | - | 待办项 ID |
| `title` | `string` | 否 | `null` | 新标题 |
| `priority` | `string` | 否 | `null` | 新优先级:`Low` / `Medium` / `High` |
**未找到时**:返回文本 `"未找到 ID 为 {id} 的待办项"`
---
#### ToggleTodoComplete
切换待办项的完成状态(已完成 ↔ 未完成)。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `int` | 是 | 待办项 ID |
**返回示例**
```
已切换完成状态:
{
"id": 1,
"title": "完成项目报告",
"priority": "High",
"isCompleted": true,
...
}
```
---
#### DeleteTodo
删除指定待办项。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `int` | 是 | 待办项 ID |
**成功时**:返回文本 `"已删除待办项 {id}"`
**未找到时**:返回文本 `"未找到 ID 为 {id} 的待办项"`
---
## 4. 数据类型定义
### 4.1 TaskDto(待办项)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | `int` | 唯一标识符 |
| `title` | `string` | 标题 |
| `priority` | `string` | 优先级枚举值:`"Low"` / `"Medium"` / `"High"` |
| `isCompleted` | `bool` | 是否已完成 |
| `createdAt` | `string` | 创建时间(ISO 8601 UTC |
| `updatedAt` | `string` | 更新时间(ISO 8601 UTC |
| `parentTaskId` | `int?` | 父待办项 ID(顶层任务为 null) |
| `subTasks` | `TaskDto[]` | 子任务列表(递归结构) |
### 4.2 优先级枚举
| 值 | 说明 |
|---|---|
| `"Low"` | 低优先级 |
| `"Medium"` | 中优先级(默认) |
| `"High"` | 高优先级 |
---
## 5. 错误处理
MCP 工具调用中的错误通过返回文本内容表达(而非 MCP 协议级错误),常见情况:
| 场景 | 返回内容 |
|---|---|
| 待办项不存在 | `"未找到 ID 为 {id} 的待办项"` |
| 优先级值无效 | `"无效的优先级 'xxx',有效值为:Low、Medium、High"` |
| 列表为空 | `"没有{label}"` |
MCP 协议级错误(如方法不存在、参数格式错误)遵循 JSON-RPC 2.0 标准:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32600,
"message": "Invalid Request"
}
}
```
---
## 6. 与 HTTP API 的对照
MCP 工具与原有 HTTP Dynamic API 的对应关系:
| MCP 工具 | HTTP API | 说明 |
|---|---|---|
| `ListAllTodos` | `GET /api/task` | 获取全部 |
| `ListActiveTodos` | `GET /api/task/active` | 获取未完成 |
| `ListCompletedTodos` | `GET /api/task/completed` | 获取已完成 |
| `GetTodoById` | `GET /api/task/{id}` | 按 ID 查询 |
| `CreateTodo` | `POST /api/task` | 创建 |
| `UpdateTodo` | `PUT /api/task` | 更新 |
| `ToggleTodoComplete` | `PATCH /api/task/{id}/toggle` | 切换完成 |
| `DeleteTodo` | `DELETE /api/task/{id}` | 删除 |
| `ListSubTodos` | `GET /api/task/{parentTaskId}/subtasks` | 子任务 |
> 两套 API 共享同一 `ITaskService` 实现,数据完全一致,可根据场景选择使用。
---
## 7. 安全建议(生产部署)
1. **网络隔离**:MCP 端点默认无认证,建议仅在可信网络内暴露,或通过反向代理添加 API Key / Bearer Token 认证
2. **CORS 限制**:生产环境应将 CORS 策略从 `AllowAll` 改为指定来源
3. **HTTPS**:生产环境必须启用 HTTPS
4. **速率限制**:建议对 MCP 端点添加请求速率限制
---
## 8. 常见问题
### Q: MCP 端点和 HTTP API 可以同时使用吗?
可以。两者共享相同的数据源和服务层,不存在冲突。
### Q: Streamable HTTP 无状态模式下支持服务端通知吗?
不支持。无状态模式下服务端无法向客户端主动推送通知。如需通知能力,需改为有状态模式(`Stateless = false`),但需要会话亲和。
### Q: 如何验证 MCP 服务是否正常运行?
发送 `initialize` 请求(见 2.2 节),若返回服务端信息则表示正常。
---
**文档版本**1.0.0
**最后更新**2026-06-16
@@ -1,6 +1,6 @@
# Todo 待办项云同步规则
> 本文档汇总 Hua.Todo 项目的 Todo 待办项云同步完整规则,涵盖架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等。
> 本文档面向开发者,汇总 Hua.Todo 项目的云同步完整规则,涵盖架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等。
>
> 术语:本文中"任务 / Todo 待办项"均为业务实体(对应代码 `Task` / `SubTask` / `TaskEntity`),与编码侧的"研发工单"无关。
@@ -509,4 +509,4 @@ Hua.Todo.Application (共享层)
> - 安全与可控落盘:[06-CloudSync-安全与可控落盘.md](../project/研发工单-v1.2.0/06-CloudSync-安全与可控落盘.md)
> - 安全设计方案:[06.1-CloudSync-服务端安全设计方案.md](../project/研发工单-v1.2.0/06.1-CloudSync-服务端安全设计方案.md)
> - 同源 Host 重构:[08-cloud_sync_refactor_plan.md](../project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md)
> - 技术设计文档:[技术设计文档.md](./01-技术设计文档.md)
> - 技术架构设计:[07-技术架构设计](./07-技术架构设计.md)
-210
View File
@@ -1,210 +0,0 @@
# MCP 前端集成指南(Hua.Todo 内部使用)
> 适用范围:Hua.Todo 前端(Vue / TypeScript)团队,通过 MCP 协议与后端交互。
> 外部系统接入请参阅 [08-MCP服务接口文档.md](./08-MCP服务接口文档.md)。
---
## 一、背景
v1.3.0 起,Hua.Todo 后端在原有 Dynamic API`/api/*`)基础上,新增了 MCPModel Context Protocol)服务端点。前端可根据场景选择:
| 通道 | 端点 | 适用场景 |
|---|---|---|
| Dynamic API | `/api/task/*` | WebView 内常规 CRUD、已有逻辑兼容 |
| MCP | `/mcp` | AI 辅助、语音指令、外部工具集成 |
两套通道共享同一 `ITaskService` 业务层,数据一致。
---
## 二、连接方式
### 2.1 嵌入式模式(MAUI / Avalonia + WebView
```
MCP 端点:http://localhost:5057/mcp
```
当前嵌入式宿主**仅暴露 Dynamic API**,MCP 端点在嵌入式模式下同样可用(`AddMcpServerServices` 已注册)。
### 2.2 Host 模式(独立服务端)
```
MCP 端点:http://<host>:5173/mcp
```
开发环境通过 Vite proxy 可直接访问 `/mcp`
---
## 三、前端 MCP 客户端选型
### 3.1 推荐方案:`@modelcontextprotocol/sdk`
官方 TypeScript MCP SDK,支持 Streamable HTTP 传输。
```bash
npm install @modelcontextprotocol/sdk
```
### 3.2 连接示例
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamablehttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:5057/mcp")
);
const client = new Client({
name: "hua-todo-frontend",
version: "1.3.0",
});
await client.connect(transport);
// 列出所有可用工具
const tools = await client.listTools();
console.log("可用工具:", tools);
// 调用工具
const result = await client.callTool({
name: "ListActiveTodos",
arguments: {},
});
console.log("未完成待办项:", result);
```
### 3.3 封装建议
`src/Hua.Todo.Web/src/api/` 下新增 `mcpClient.ts`
```typescript
// src/api/mcpClient.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamablehttp.js";
const MCP_BASE_URL = window.__API_BASE_URL__
? window.__API_BASE_URL__.replace("/api", "/mcp")
: "/mcp";
let client: Client | null = null;
/** 获取或创建 MCP 客户端单例 */
export async function getMcpClient(): Promise<Client> {
if (!client) {
const transport = new StreamableHTTPClientTransport(
new URL(MCP_BASE_URL, window.location.origin)
);
client = new Client({
name: "hua-todo-frontend",
version: "1.3.0",
});
await client.connect(transport);
}
return client;
}
/** 断开 MCP 连接 */
export async function disconnectMcp(): Promise<void> {
if (client) {
await client.close();
client = null;
}
}
```
---
## 四、工具调用映射(MCP vs Dynamic API
| 业务操作 | Dynamic API | MCP Tool | MCP 参数 |
|---|---|---|---|
| 获取所有待办项 | `GET /api/task` | `ListAllTodos` | 无 |
| 获取未完成待办项 | `GET /api/task/active` | `ListActiveTodos` | 无 |
| 获取已完成待办项 | `GET /api/task/completed` | `ListCompletedTodos` | 无 |
| 获取单个待办项 | `GET /api/task/{id}` | `GetTodoById` | `id: number` |
| 创建待办项 | `POST /api/task` | `CreateTodo` | `title, priority?, parentTaskId?` |
| 更新待办项 | `PUT /api/task` | `UpdateTodo` | `id, title?, priority?` |
| 切换完成状态 | `PATCH /api/task/{id}/toggle` | `ToggleTodoComplete` | `id: number` |
| 删除待办项 | `DELETE /api/task/{id}` | `DeleteTodo` | `id: number` |
| 获取子待办项 | `GET /api/task/{pid}/subtasks` | `ListSubTodos` | `parentTaskId: number` |
---
## 五、返回格式差异
### Dynamic API 返回
```json
{
"success": true,
"data": [{ "id": 1, "title": "...", ... }],
"message": "",
"errors": []
}
```
### MCP Tool 返回
MCP 工具返回 `string` 类型,分两种格式:
**列表类**(可读文本):
```
未完成待办项(共 2 项):
[1] 完成报告 | 优先级:High | 进行中
[3] 买菜 | 优先级:Low | 进行中 | 父ID:2
```
**单条/创建/更新类**JSON):
```json
{
"id": 1,
"title": "完成报告",
"priority": "High",
"isCompleted": false,
"createdAt": "2026-06-16T08:00:00Z",
"updatedAt": "2026-06-16T08:00:00Z",
"parentTaskId": null,
"subTasks": []
}
```
> 前端如果需要结构化数据,建议仍使用 Dynamic API;MCP 通道主要用于 AI 场景和文本交互。
---
## 六、典型场景
### 6.1 AI 对话式操作
用户通过 AI 助手(接入 MCP 的 LLM 客户端)用自然语言操作待办项:
```
用户:帮我看看还有哪些事没做完
AI:→ 调用 ListActiveTodos
AI:您有 2 项未完成的待办:[1] 完成报告 [3] 买菜
用户:把"完成报告"标为已完成
AI:→ 调用 ToggleTodoComplete(id=1)
AI:已将"完成报告"标记为完成
```
### 6.2 语音指令
语音 → STT → 文本 → LLM 解析意图 → MCP Tool 调用 → TTS 播报结果。
### 6.3 外部工具集成
IDE 插件、自动化脚本等通过 MCP 协议直接操作待办项,无需理解 HTTP API 细节。
---
## 七、注意事项
1. **MCP 无状态模式**:当前配置为 Stateless,不支持服务端→客户端通知;如需实时推送仍走 Dynamic API 或 WebSocket
2. **CORS**:开发环境 `AllowAll` 策略已覆盖 `/mcp`;生产环境需按需配置
3. **认证**:当前 MCP 端点未接入认证中间件;如需鉴权,需在 `MapMcpServer` 后追加 `.RequireAuthorization()`
4. **生命周期**:MCP 客户端连接为长连接,建议在组件 `onUnmounted` 时调用 `disconnectMcp()`
@@ -1,4 +1,6 @@
# Hua.Todo 代码规范文档 v1.2.8
# 代码规范
> 本文档面向开发者,定义 Hua.Todo 项目的编码规范。
## 1. 概述
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
@@ -12,9 +14,11 @@
### 2.2 注释规范 (强制)
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
- ** summary**:一句话说明用途
- ** param / returns**:关键参数/返回值说明
- ** 异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
- **summary**:一句话说明用途,不允许重复嵌套 `<summary>` 标签
- **param / returns**:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns`
- **异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
- **XML 注释位置**`///` 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明
- **cref 使用**`<see cref="..."/>` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574 警告
- **复杂逻辑添加行内注释**
- **禁止在日志或注释中输出密钥、Token、用户隐私信息**
@@ -29,7 +33,7 @@
### 3.1 跨平台逻辑规范
- **禁止混写 `#if`**:禁止在同一文件内混写多个平台的大段 `#if` 实现。
- **优先使用 partial/接口**:应优先使用 `partial` 类、接口与平台目录分离。
- **说明平台差异**:平台分离后的公共入口处必须说明平台差异在哪里、默认实现是什么、为什么这么做
- **说明平台差异**:平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"
### 3.2 异步/后台任务
- **必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入**
@@ -78,7 +82,7 @@ public void CreateTask(string title, TaskPriority priority)
public const int MaxTaskTitleLength = 200;
```
### 3.2 代码组织
### 3.3 代码组织
#### 文件结构
```csharp
@@ -90,10 +94,11 @@ using System.Threading.Tasks;
// 2. 命名空间
namespace Hua.Todo.Api.Services;
// 3. XML 文档注释
// 3. XML 文档注释(必须位于特性和声明之前)
/// <summary>
/// 任务服务实现
/// 任务服务实现
/// </summary>
[SomeAttribute]
public class TaskService : ITaskService
{
// 4. 私有字段
@@ -124,7 +129,7 @@ public class TaskService : ITaskService
- 命名空间结构应与目录结构一致
- 使用 `.` 分隔层级
### 3.3 编码规范
### 3.4 编码规范
#### 异步编程
```csharp
@@ -191,7 +196,7 @@ var query = from task in tasks
select task;
```
### 3.4 文档注释
### 3.5 文档注释
```csharp
/// <summary>
/// 获取指定 ID 的任务
+311
View File
@@ -0,0 +1,311 @@
# MCP 服务集成
> 本文档涵盖 Hua.Todo MCP 服务的两部分内容:面向外部系统的接口规范(Part A)与面向内部前端的集成指南(Part B)。
---
## Part A:MCP 服务接口规范(外部系统接入)
### A.1 概述
Hua.Todo 提供了基于 **Model Context Protocol (MCP)** 的服务端,允许外部 MCP 客户端通过 Streamable HTTP 传输协议发现并调用待办项管理工具。
#### A.1.1 协议与传输
| 项目 | 说明 |
|---|---|
| 协议版本 | MCP 2025-03-26Streamable HTTP |
| 传输方式 | Streamable HTTP(无状态模式) |
| 内容格式 | JSON-RPC 2.0 |
| 端点路径 | `/mcp` |
| 认证 | 暂无(本地模式);生产环境建议通过反向代理添加认证 |
#### A.1.2 服务端信息
```json
{
"name": "Hua.Todo MCP Server",
"version": "1.0.0"
}
```
#### A.1.3 连接地址
| 运行模式 | 默认地址 |
|---|---|
| Hua.Todo.Host(独立服务端) | `http://localhost:5173/mcp` |
| MAUI / Avalonia(嵌入式) | `http://localhost:5057/mcp` |
### A.2 接入方式
#### A.2.1 MCP 客户端配置示例
**Claude Desktop / Cursor / 其他 MCP 客户端** 配置文件:
```json
{
"mcpServers": {
"hua-todo": {
"url": "http://localhost:5173/mcp",
"transport": "streamable-http"
}
}
}
```
#### A.2.2 手动调用示例(curl
**初始化连接**
```bash
curl -X POST http://localhost:5173/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "my-client", "version": "1.0.0" }
}
}'
```
**列出可用工具**
```bash
curl -X POST http://localhost:5173/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}'
```
**调用工具**
```bash
curl -X POST http://localhost:5173/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "CreateTodo", "arguments": { "title": "完成项目报告", "priority": "High" } }
}'
```
### A.3 工具清单
#### A.3.1 查询类工具
**ListAllTodos** — 获取所有待办项列表(含已完成和未完成),无参数。
**ListActiveTodos** — 获取未完成的待办项列表,无参数。
**ListCompletedTodos** — 获取已完成的待办项列表,无参数。
**GetTodoById** — 根据 ID 获取单个待办项详情(含子任务)。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `int` | 是 | 待办项 ID |
**返回示例**
```json
{
"id": 1, "title": "完成项目报告", "priority": "High",
"isCompleted": false, "createdAt": "2026-06-16T08:30:00Z",
"updatedAt": "2026-06-16T08:30:00Z", "parentTaskId": null,
"subTasks": [{ "id": 4, "title": "收集数据", "priority": "Medium", "isCompleted": true, "parentTaskId": 1, "subTasks": [] }]
}
```
**ListSubTodos** — 获取指定父待办项下的所有子待办项。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `parentTaskId` | `int` | 是 | 父待办项 ID |
#### A.3.2 写入类工具
**CreateTodo** — 创建新的待办项。
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `title` | `string` | 是 | - | 待办项标题 |
| `priority` | `string` | 否 | `"Medium"` | 优先级:`Low` / `Medium` / `High` |
| `parentTaskId` | `int` | 否 | `null` | 父待办项 ID |
**UpdateTodo** — 更新已有待办项的标题或优先级。
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `id` | `int` | 是 | - | 待办项 ID |
| `title` | `string` | 否 | `null` | 新标题 |
| `priority` | `string` | 否 | `null` | 新优先级 |
**ToggleTodoComplete** — 切换待办项的完成状态。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `int` | 是 | 待办项 ID |
**DeleteTodo** — 删除指定待办项。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `int` | 是 | 待办项 ID |
### A.4 数据类型定义
#### TaskDto(待办项)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | `int` | 唯一标识符 |
| `title` | `string` | 标题 |
| `priority` | `string` | `"Low"` / `"Medium"` / `"High"` |
| `isCompleted` | `bool` | 是否已完成 |
| `createdAt` | `string` | 创建时间(ISO 8601 UTC |
| `updatedAt` | `string` | 更新时间(ISO 8601 UTC |
| `parentTaskId` | `int?` | 父待办项 ID |
| `subTasks` | `TaskDto[]` | 子任务列表 |
### A.5 与 HTTP API 的对照
| MCP 工具 | HTTP API | 说明 |
|---|---|---|
| `ListAllTodos` | `GET /api/task` | 获取全部 |
| `ListActiveTodos` | `GET /api/task/active` | 获取未完成 |
| `ListCompletedTodos` | `GET /api/task/completed` | 获取已完成 |
| `GetTodoById` | `GET /api/task/{id}` | 按 ID 查询 |
| `CreateTodo` | `POST /api/task` | 创建 |
| `UpdateTodo` | `PUT /api/task` | 更新 |
| `ToggleTodoComplete` | `PATCH /api/task/{id}/toggle` | 切换完成 |
| `DeleteTodo` | `DELETE /api/task/{id}` | 删除 |
| `ListSubTodos` | `GET /api/task/{parentTaskId}/subtasks` | 子任务 |
> 两套 API 共享同一 `ITaskService` 实现,数据完全一致。
### A.6 安全建议(生产部署)
1. **网络隔离**:MCP 端点默认无认证,建议仅在可信网络内暴露,或通过反向代理添加认证
2. **CORS 限制**:生产环境应将 CORS 策略从 `AllowAll` 改为指定来源
3. **HTTPS**:生产环境必须启用 HTTPS
4. **速率限制**:建议对 MCP 端点添加请求速率限制
---
## Part B:MCP 前端集成指南(内部使用)
> 适用范围:Hua.Todo 前端(Vue / TypeScript)团队。
### B.1 背景
v1.3.0 起,Hua.Todo 后端在原有 Dynamic API`/api/*`)基础上,新增了 MCP 服务端点。前端可根据场景选择:
| 通道 | 端点 | 适用场景 |
|---|---|---|
| Dynamic API | `/api/task/*` | WebView 内常规 CRUD、已有逻辑兼容 |
| MCP | `/mcp` | AI 辅助、语音指令、外部工具集成 |
两套通道共享同一 `ITaskService` 业务层,数据一致。
### B.2 连接方式
| 模式 | MCP 端点 |
|---|---|
| 嵌入式(MAUI / Avalonia | `http://localhost:5057/mcp` |
| Host(独立服务端) | `http://<host>:5173/mcp` |
### B.3 前端 MCP 客户端
推荐使用官方 TypeScript MCP SDK
```bash
npm install @modelcontextprotocol/sdk
```
**连接示例**
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamablehttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:5057/mcp")
);
const client = new Client({ name: "hua-todo-frontend", version: "1.3.0" });
await client.connect(transport);
// 列出所有可用工具
const tools = await client.listTools();
// 调用工具
const result = await client.callTool({ name: "ListActiveTodos", arguments: {} });
```
**封装建议**`src/api/mcpClient.ts`):
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamablehttp.js";
const MCP_BASE_URL = window.__API_BASE_URL__
? window.__API_BASE_URL__.replace("/api", "/mcp")
: "/mcp";
let client: Client | null = null;
export async function getMcpClient(): Promise<Client> {
if (!client) {
const transport = new StreamableHTTPClientTransport(
new URL(MCP_BASE_URL, window.location.origin)
);
client = new Client({ name: "hua-todo-frontend", version: "1.3.0" });
await client.connect(transport);
}
return client;
}
export async function disconnectMcp(): Promise<void> {
if (client) { await client.close(); client = null; }
}
```
### B.4 工具调用映射
| 业务操作 | Dynamic API | MCP Tool |
|---|---|---|
| 获取所有待办项 | `GET /api/task` | `ListAllTodos` |
| 获取未完成待办项 | `GET /api/task/active` | `ListActiveTodos` |
| 获取已完成待办项 | `GET /api/task/completed` | `ListCompletedTodos` |
| 获取单个待办项 | `GET /api/task/{id}` | `GetTodoById` |
| 创建待办项 | `POST /api/task` | `CreateTodo` |
| 更新待办项 | `PUT /api/task` | `UpdateTodo` |
| 切换完成状态 | `PATCH /api/task/{id}/toggle` | `ToggleTodoComplete` |
| 删除待办项 | `DELETE /api/task/{id}` | `DeleteTodo` |
| 获取子待办项 | `GET /api/task/{pid}/subtasks` | `ListSubTodos` |
### B.5 返回格式差异
- **列表类** MCP 工具返回可读文本(如 `"未完成待办项(共 2 项):[1] 完成报告..."`
- **单条/创建/更新类** MCP 工具返回 JSON
- 前端如需结构化数据,建议仍使用 Dynamic API;MCP 通道主要用于 AI 场景和文本交互
### B.6 典型场景
**AI 对话式操作**:用户通过 AI 助手用自然语言操作待办项 → LLM 调用 MCP 工具 → 返回结果。
**语音指令**:语音 → STT → 文本 → LLM 解析意图 → MCP Tool 调用 → TTS 播报结果。
### B.7 注意事项
1. **无状态模式**:当前不支持服务端→客户端通知
2. **CORS**:开发环境 `AllowAll` 策略已覆盖 `/mcp`
3. **认证**:当前 MCP 端点未接入认证中间件
4. **生命周期**:建议在组件 `onUnmounted` 时调用 `disconnectMcp()`
---
> MCP 服务端实现细节见 [07-技术架构设计](./07-技术架构设计.md) 中 MCP 工具注册部分。
@@ -711,7 +711,7 @@ foreach (var id in request.Deletes)
| `src/Hua.Todo.Web/src/api/cloudSync.ts` | API 响应类型对齐 | 否 |
| `src/Hua.Todo.Web/src/stores/taskStore.ts` | pendingUpserts/pendingDeletes 类型修正 | 否 |
| `src/Hua.Todo.Web/src/utils/guid.ts` | **新增 Guid 工具函数** | 否 |
| `docs/manual/02-任务同步规则.md` | 文档修正(字段命名) | 是(文档) |
| `docs/manual/04-云同步规则.md` | 文档修正(字段命名) | 是(文档) |
| `.trae/rules/项目/03-数据模型与迁移约束.md` | 实体清单更新 | 是(规则) |
---
@@ -22,10 +22,10 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 工单编号 | 标题 | 负责人 | 状态 |
|---|---|---|---|---|---|---|
| 01 | HTTP 服务转换为 MCP 服务 | - | 进行中 |
| 02 | 语音控制与 AI 辅助 | - | 待开始 |
| 01 | HTTP 服务转换为 MCP 服务 | - | 已完成 |
| 02 | 语音控制与 AI 辅助 | - | 已完成 |
| 03 | 会议任务拆分 | - | 待开始 |
| 04 | 富文本描述、附件与外部链接 | - | 待开始 |
| 04 | 富文本描述、附件与外部链接 | - | 已完成 |
### 2.2 03 子工单拆分
@@ -34,7 +34,7 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 03-01 | 会议数据模型与 API | 无 | 待开始 |
| 03-02 | 音频录制与转写 | 03-01 | 待开始 |
| 03-03 | AI 任务拆分服务 | 03-01、工单 02 LlmClientService | 待开始 |
| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 待开始 |
| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 已完成 |
### 2.3 串行工单(依赖前置工单完成)
@@ -129,9 +129,10 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 子工单 | 验证项 | 状态 | 备注 |
|---|---|---|---|
| 01 | MCP 服务契约文档生成 | 验证 | - |
| 01 | MCP 服务可用性测试 | 验证 | - |
| 01 | 原有 API 功能兼容性 | 验证 | - |
| 01 | MCP 服务契约文档生成 | 验证 | 动态工具自动生成,工具名/描述/参数 schema 均已覆盖 |
| 01 | MCP 服务可用性测试 | 验证 | 17 个单元测试全部通过(含工具调用端到端验证) |
| 01 | 原有 API 功能兼容性 | 验证 | 所有 ITaskService 方法均生成 MCP 工具,覆盖 CRUD 全部 9 个 API |
| 01 | CloudSync MCP 工具 | 已知缺口 | CloudSync 服务未实现 IDynamicApiService,需手动映射或重构 |
| 02 | STT/TTS 平台适配 | 待验证 | Windows 优先,其他平台后续 |
| 02 | 语音指令识别准确率 | 待验证 | - |
| 02 | 歧义处理正确性 | 待验证 | - |
@@ -140,7 +141,7 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 03 | 会议数据模型迁移 | 待验证 | TaskType 字段 + DB 迁移 |
| 03 | 录音与转写链路 | 待验证 | 录制 → 上传 → 转写 → 保存 |
| 03 | AI 会议拆分质量 | 待验证 | 建议含标题+优先级+原因 |
| 03 | 建议审阅与批量创建 | 待验证 | 勾选/编辑/确认后创建子任务 |
| 03 | 建议审阅与批量创建 | 待验证 | MeetingBreakdownDialog 已实现:勾选/编辑/确认后创建子任务 |
| 04 | 描述字段编辑与保存 | 待验证 | - |
| 04 | 附件上传/下载/删除 | 待验证 | - |
| 04 | 外部链接添加与打开 | 待验证 | - |
+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
+8 -1
View File
@@ -57,7 +57,14 @@ if (-not (Test-Path ".\.codegraph")) {
} else {
Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan
codegraph sync
if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" }
if ($LASTEXITCODE -ne 0) {
Write-Host "[警告] 增量同步失败,可能未正确初始化,尝试重新初始化…" -ForegroundColor Yellow
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 重新初始化完毕,重新执行同步…" -ForegroundColor Green
codegraph sync
if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" }
}
Write-Host "[完成] 增量同步完毕" -ForegroundColor Green
}
@@ -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 Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Data.Converters;
namespace Hua.Todo.Application.Common.Converters;
/// <summary>
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601Round-trip)字符串形式持久化到 SQLite,
/// 并在读取时兼容历史遗留的 ticks/Unix 时间戳 数字字符串,避免因脏数据导致查询失败。
/// 并在读取时兼容历史遗留的 "ticks/Unix 时间戳" 数字字符串,避免因脏数据导致查询失败。
/// </summary>
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.Extensions.DependencyInjection;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services;
using Hua.Todo.Application.Services.Meeting;
using Hua.Todo.Application.Services.Voice;
using Hua.Todo.Core.Interfaces;
using ITaskService = Hua.Todo.Application.Interfaces.ITaskService;
using ITaskService = Hua.Todo.Application.Services.Interfaces.ITaskService;
namespace Hua.Todo.Application;
namespace Hua.Todo.Application.Common;
/// <summary>
/// 应用层依赖注入扩展。
@@ -25,7 +25,13 @@ public static class ServiceCollectionExtensions
services.AddDbContext<TodoDbContext>(options =>
options.UseSqlite(connectionString, b => b.MigrationsAssembly("Hua.Todo.Application")));
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IAttachmentRepository, AttachmentRepository>();
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IAttachmentService, AttachmentService>();
services.AddScoped<IMeetingService, MeetingService>();
services.AddScoped<MeetingAiBreakdownService>();
services.AddScoped<ISttService, SttService>();
services.AddVoiceServices();
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>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<!-- ASP.NET Core 仅 net10.0(移动端通过 csproj 排除依赖文件) -->
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- 移动端排除依赖 ASP.NET Core 的 CloudSync I/O 文件 -->
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="Mcp\\**\\*.cs" />
</ItemGroup>
<!-- 测试用:-p:SkipCloudSync=true 排除 CloudSync 编译(其依赖 TaskEntity ABP 重构未完成) -->
<ItemGroup Condition="'$(SkipCloudSync)' == 'true'">
<Compile Remove="CloudSync\\**\\*.cs" />
<Compile Remove="Services\CloudSync\Services\CloudSyncProxyService.cs" />
<Compile Remove="Services\CloudSync\Services\CloudSyncServerService.cs" />
<Compile Remove="Services\CloudSync\Services\CloudSyncProxySettingsService.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.5" />
<PackageReference Include="Serilog" Version="4.3.2-dev-02433" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" />
<ProjectReference Include="..\Hua.Todo.HttpApi\Hua.Todo.HttpApi.csproj" />
</ItemGroup>
</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>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 父任务 ID(用于创建子任务)。
/// </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>
/// 更新任务请求 DTO。
/// 未传递的字段保持原值不变,传递 null 表示清空。
/// </summary>
public class UpdateTaskDto
{
/// <summary>
/// 任务 ID。
/// </summary>
public int Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// 新标题(可选)。
/// 新标题(可选,不传则保持原值)。
/// </summary>
public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 新优先级(可选)。
/// 新优先级(可选,不传则保持原值)。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; }
/// <summary>
/// 待办项类型(可选,不传则保持原值)。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskType? TaskType { get; set; }
/// <summary>
/// 多行描述文本(可选,不传则保持原值,传 null 则清空)。
/// </summary>
public string? Description { get; set; }
}
/// <summary>
@@ -55,16 +78,22 @@ public class TaskDto
/// <summary>
/// 任务 ID。
/// </summary>
public int Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// 任务编号(用户级自增字符串,用于展示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; }
/// <summary>
@@ -82,11 +111,42 @@ public class TaskDto
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
public Guid? ParentTaskId { get; set; }
/// <summary>
/// 子任务列表。
/// </summary>
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>
@@ -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 Hua.Todo.Application.Data;
using Hua.Todo.Application.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("MakeTaskEntityAbpCompatible")]
partial class MakeTaskEntityAbpCompatible
[Migration("20260616203619_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -20,6 +20,50 @@ namespace Hua.Todo.Application.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b =>
{
b.Property<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 =>
{
b.Property<Guid>("Id")
@@ -43,8 +87,7 @@ namespace Hua.Todo.Application.Migrations
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
b.Property<DateTime>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
@@ -83,9 +126,7 @@ namespace Hua.Todo.Application.Migrations
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
@@ -109,6 +150,14 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<double?>("AudioDuration")
.HasColumnType("REAL");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
@@ -120,10 +169,14 @@ namespace Hua.Todo.Application.Migrations
b.Property<Guid?>("CreatorId")
.HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
b.Property<string>("Description")
.HasMaxLength(5000)
.HasColumnType("TEXT");
b.Property<string>("ExtraProperties")
@@ -134,10 +187,6 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -149,6 +198,10 @@ namespace Hua.Todo.Application.Migrations
b.Property<Guid?>("LastModifierId")
.HasColumnType("TEXT");
b.Property<string>("MeetingNotes")
.HasMaxLength(20000)
.HasColumnType("TEXT");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("TEXT");
@@ -157,15 +210,18 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<int>("TaskType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
.HasColumnType("TEXT");
b.HasKey("Id");
@@ -223,15 +279,13 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
@@ -244,6 +298,17 @@ namespace Hua.Todo.Application.Migrations
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "Task")
.WithMany("Attachments")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
@@ -286,6 +351,8 @@ namespace Hua.Todo.Application.Migrations
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("Attachments");
b.Navigation("SubTasks");
});
@@ -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 />
using System;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
@@ -11,15 +10,57 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260510171230_AddMustChangePasswordToUsers")]
partial class AddMustChangePasswordToUsers
partial class TodoDbContextModelSnapshot : ModelSnapshot
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b =>
{
b.Property<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 =>
{
b.Property<Guid>("Id")
@@ -43,8 +84,7 @@ namespace Hua.Todo.Application.Migrations
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
b.Property<DateTime>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
@@ -83,9 +123,7 @@ namespace Hua.Todo.Application.Migrations
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
@@ -105,44 +143,82 @@ namespace Hua.Todo.Application.Migrations
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
b.Property<double?>("AudioDuration")
.HasColumnType("REAL");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreationTime")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.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")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.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")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<int>("TaskType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
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"));
.HasColumnType("TEXT");
b.HasKey("Id");
@@ -150,7 +226,7 @@ namespace Hua.Todo.Application.Migrations
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
b.ToTable("T_Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
@@ -200,15 +276,13 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
@@ -221,6 +295,17 @@ namespace Hua.Todo.Application.Migrations
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.AttachmentEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "Task")
.WithMany("Attachments")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
@@ -263,6 +348,8 @@ namespace Hua.Todo.Application.Migrations
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("Attachments");
b.Navigation("SubTasks");
});
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces;
@@ -28,7 +27,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Include(t => t.SubTasks)
.Include(t => t.SubTasks!)
.ToListAsync();
}
@@ -37,10 +36,10 @@ public class TaskRepository : ITaskRepository
/// </summary>
/// <param name="id">任务 ID。</param>
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
public async Task<TaskEntity?> GetByIdAsync(int id)
public async Task<TaskEntity?> GetByIdAsync(Guid id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.Include(t => t.SubTasks!)
.FirstOrDefaultAsync(t => t.Id == id);
}
@@ -52,7 +51,7 @@ public class TaskRepository : ITaskRepository
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.OrderByDescending(t => t.CreationTime)
.ToListAsync();
}
@@ -64,7 +63,7 @@ public class TaskRepository : ITaskRepository
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.OrderByDescending(t => t.LastModificationTime)
.ToListAsync();
}
@@ -87,7 +86,7 @@ public class TaskRepository : ITaskRepository
/// <returns>更新后的任务实体。</returns>
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{
taskEntity.UpdatedAt = DateTime.UtcNow;
taskEntity.LastModificationTime = DateTime.UtcNow;
_context.Tasks.Update(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
@@ -98,7 +97,7 @@ public class TaskRepository : ITaskRepository
/// </summary>
/// <param name="id">要删除的任务 ID。</param>
/// <returns>表示删除操作的任务。</returns>
public async Task DeleteAsync(int id)
public async Task DeleteAsync(Guid id)
{
var task = await _context.Tasks.FindAsync(id);
if (task != null)
@@ -113,11 +112,35 @@ public class TaskRepository : ITaskRepository
/// </summary>
/// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
public async Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.OrderByDescending(t => t.CreationTime)
.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;
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。
@@ -14,7 +14,7 @@ public static class ClaimsPrincipalExtensions
/// <returns>用户 ID。</returns>
public static Guid? GetUserId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.NameIdentifier);
var value = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
@@ -25,7 +25,7 @@ public static class ClaimsPrincipalExtensions
/// <returns>会话 ID。</returns>
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;
}
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 云同步鉴权使用的 Claim 名称约定。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 云同步权限点常量。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 默认的角色-权限映射(内置最小集合)。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 角色到权限集合的映射器(RBAC 最小落地)。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 云同步会话鉴权默认配置。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary>
/// 用户信息 DTO(管理端使用)。
@@ -27,6 +27,14 @@ public class CreateUserRequest
/// </summary>
public class ResetPasswordRequest
{
/// <summary>
/// 目标用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 新密码。
/// </summary>
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>
/// 云同步 API 统一错误响应结构。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary>
/// 登录请求。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary>
/// 服务端探测请求。
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary>
/// 安全策略下发 DTO。
@@ -1,7 +1,7 @@
using System.Text.Json.Serialization;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.Services.CloudSync.Models;
/// <summary>
/// 云同步任务条目(ABP 标准)。
@@ -1,15 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Core.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary>
/// 管理端服务(账户、会话、审计、全局策略管理)。
/// </summary>
public class CloudAdminService
public class CloudAdminService : ICloudAdminService
{
private readonly TodoDbContext _dbContext;
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 };
}
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);
if (user == null) return false;
@@ -122,12 +133,13 @@ public class CloudAdminService
#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
.AsNoTracking()
.OrderByDescending(l => l.TimestampUtc)
.Take(count)
.Take(take)
.Select(l => new AuditLogDto
{
Id = l.Id,
@@ -144,4 +156,16 @@ public class CloudAdminService
}
#endregion
#region ICloudAdminService CancellationToken DynamicApi
Task<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.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>
public class CloudAuthService
public class CloudAuthService : ICloudAuthService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper;
private readonly ICurrentUserAccessor? _currentUserAccessor;
private static readonly Random _random = new();
/// <summary>
@@ -23,14 +25,17 @@ public class CloudAuthService
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
/// <param name="currentUserAccessor">HTTP 上下文访问器(可选,用于获取客户端 IP/UA 等)。</param>
public CloudAuthService(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper)
IRolePermissionMapper rolePermissionMapper,
ICurrentUserAccessor? currentUserAccessor = null)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_rolePermissionMapper = rolePermissionMapper;
_currentUserAccessor = currentUserAccessor;
}
private static string GenerateRandomPassword(int length = 16)
@@ -216,7 +221,6 @@ public class CloudAuthService
};
}
/// <summary>
/// <summary>
/// 修改用户密码。
/// </summary>
@@ -284,5 +288,57 @@ public class CloudAuthService
await _dbContext.SaveChangesAsync(cancellationToken);
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 System.Net.Http;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Services.CloudSync.Models;
using Microsoft.Extensions.Logging;
namespace Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary>
/// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。
/// </summary>
public class CloudProbeService
public class CloudProbeService : ICloudProbeService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CloudProbeService> _logger;
/// <summary>
/// 创建 <see cref="CloudProbeService"/>。
/// </summary>
/// <param name="httpClientFactory">HTTP 客户端工厂。</param>
public CloudProbeService(IHttpClientFactory httpClientFactory)
/// <param name="logger">日志记录器。</param>
public CloudProbeService(IHttpClientFactory httpClientFactory, ILogger<CloudProbeService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// 探测目标 URL 的可达性。
/// </summary>
/// <param name="targetUrl">目标 URL。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <param name="request">探测请求(含 TargetUrl。</param>
/// <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();
@@ -91,7 +100,6 @@ public class CloudProbeService
}
else
{
// 非 2xx 也说明服务器可达,仅作为提示
if (!response.IsHttps)
{
response.Type = "warn";
@@ -112,6 +120,7 @@ public class CloudProbeService
response.Type = response.IsHttps ? "error" : "warn";
response.Title = "存在风险";
response.Description = "探测超时(网络不可达或目标无响应)。";
_logger.LogWarning("探测超时: {TargetUrl}", targetUrl);
}
catch (HttpRequestException ex)
{
@@ -121,15 +130,16 @@ public class CloudProbeService
response.Description = response.IsHttps
? $"探测失败:{ex.Message}"
: $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。";
_logger.LogWarning(ex, "探测请求失败: {TargetUrl}", targetUrl);
}
catch (Exception)
catch (Exception ex)
{
response.IsReachable = false;
response.Type = "error";
response.Description = "探测过程中发生未知错误。";
_logger.LogError(ex, "探测未知错误: {TargetUrl}", targetUrl);
}
return response;
}
}
@@ -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 Microsoft.EntityFrameworkCore.Storage;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary>
/// 云同步任务服务(按用户隔离)。
/// 实现 ABP 风格审计字段和 LWW 冲突解决策略。
/// </summary>
public class CloudTaskSyncService
public class CloudTaskSyncService : ICloudTaskSyncService
{
private readonly TodoDbContext _dbContext;
private readonly ILogger<CloudTaskSyncService> _logger;
private readonly ICurrentUserAccessor? _currentUserAccessor;
/// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary>
/// <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;
_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>
@@ -31,7 +61,6 @@ public class CloudTaskSyncService
/// <returns>任务列表(含已删除任务)。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{
// 使用 IgnoreQueryFilters 获取包括已删除任务的全量
var tasks = await _dbContext.Tasks
.AsNoTracking()
.IgnoreQueryFilters()
@@ -65,16 +94,12 @@ public class CloudTaskSyncService
await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken);
}
// 保存删除操作
await _dbContext.SaveChangesAsync(cancellationToken);
}
// 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理)
if (request.Upserts.Count > 0)
{
// 去重策略:
// - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送)
// - 全新任务(Id == null):全部保留,不参与去重
var dedupedById = request.Upserts
.Where(u => u.Id.HasValue)
.GroupBy(u => u.Id!.Value)
@@ -86,13 +111,9 @@ public class CloudTaskSyncService
var uniqueUpserts = dedupedById
.Concat(newTasks)
.Where(u => !string.IsNullOrWhiteSpace(u.Title))
.OrderByDescending(u => u.LastModificationTime) // 较新的先处理
.OrderByDescending(u => u.LastModificationTime)
.ToList();
// 使用事务包裹,确保读取与写入的一致性
// 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突
// ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景;
// 对于极端并发写入冲突,SaveChangesAsync 处有重试保护
const int maxSaveRetries = 3;
for (int retry = 0; ; retry++)
@@ -101,31 +122,25 @@ public class CloudTaskSyncService
try
{
// 在事务内查询已有任务,确保读取到最新已提交数据
var existingTasks = await _dbContext.Tasks
.IgnoreQueryFilters()
.Where(t => t.UserId == userId)
.ToDictionaryAsync(t => t.Id, cancellationToken);
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射)
var clientIdToEntity = new Dictionary<Guid, TaskEntity>();
// 第一遍:处理根任务(ParentTaskId == null
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
{
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
}
// 保存根任务,获取服务器分配的 ID
await _dbContext.SaveChangesAsync(cancellationToken);
// 将新创建的根任务加入 existingTasks,供子任务反查
foreach (var kvp in clientIdToEntity)
{
existingTasks[kvp.Value.Id] = kvp.Value;
}
// 第二遍:处理子任务(ParentTaskId != null
foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue))
{
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
@@ -134,27 +149,26 @@ public class CloudTaskSyncService
await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
break; // 成功,退出重试循环
break;
}
catch (DbUpdateException ex) when (retry < maxSaveRetries - 1
&& ex.InnerException is Microsoft.Data.Sqlite.SqliteException se
&& se.SqliteErrorCode == 19
&& se.Message.Contains("UNIQUE constraint"))
{
// 并发写入冲突:其他事务已插入相同 Id 的任务
// 回滚当前事务,下次循环将重新查询并正确识别已存在实体
_logger.LogWarning(ex, "同步 SaveChanges 唯一约束冲突(重试 {Retry}/{MaxRetries}: {UserId}", retry + 1, maxSaveRetries, userId);
await transaction.RollbackAsync(cancellationToken);
// 清理 ChangeTracker,避免下次循环携带已回滚的实体状态
foreach (var entry in _dbContext.ChangeTracker.Entries<TaskEntity>().ToList())
{
entry.State = EntityState.Detached;
}
continue; // 重试
continue;
}
catch
catch (Exception ex)
{
_logger.LogError(ex, "同步 SaveChanges 失败(非可重试异常): {UserId}", userId);
await transaction.RollbackAsync(cancellationToken);
throw;
}
@@ -168,9 +182,6 @@ public class CloudTaskSyncService
};
}
/// <summary>
/// 递归软删除任务及其子任务。
/// </summary>
private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken)
{
var task = await _dbContext.Tasks
@@ -183,10 +194,8 @@ public class CloudTaskSyncService
task.DeletionTime = DateTime.UtcNow;
task.DeleterId = userId;
// 显式标记为已修改
_dbContext.Entry(task).State = EntityState.Modified;
// 递归删除子任务
var childTasks = await _dbContext.Tasks
.IgnoreQueryFilters()
.Where(t => t.ParentTaskId == taskId)
@@ -199,9 +208,6 @@ public class CloudTaskSyncService
}
}
/// <summary>
/// 处理单个 upsert 请求(创建或更新)。
/// </summary>
private async Task ProcessUpsertAsync(
Guid userId,
CloudTaskUpsert upsert,
@@ -209,7 +215,6 @@ public class CloudTaskSyncService
Dictionary<Guid, TaskEntity> clientIdToEntity,
CancellationToken cancellationToken)
{
// 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID
Guid? parentId = upsert.ParentTaskId;
if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity))
{
@@ -219,14 +224,11 @@ public class CloudTaskSyncService
TaskEntity? entity = null;
bool shouldAdd = false;
// 尝试从已缓存的字典中查找
TaskEntity? existingEntity = null;
bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity);
if (!foundInCache && upsert.Id.HasValue)
{
// 字典未命中,但客户端提供了 Id —— 可能是跨请求/跨 UserId 的遗留数据
// 直接查询 DB 以确保不会重复插入
existingEntity = await _dbContext.Tasks
.IgnoreQueryFilters()
.AsNoTracking()
@@ -235,12 +237,10 @@ public class CloudTaskSyncService
if (existingEntity != null)
{
// 实体已存在(字典或 DB 中),进行 LWW 冲突判断
if (upsert.LastModificationTime.HasValue &&
(!existingEntity.LastModificationTime.HasValue ||
upsert.LastModificationTime >= existingEntity.LastModificationTime))
{
// 客户端版本更新,接受更新
entity = existingEntity;
entity.Title = (upsert.Title ?? string.Empty).Trim();
entity.Priority = upsert.Priority;
@@ -250,7 +250,6 @@ public class CloudTaskSyncService
entity.LastModificationTime = upsert.LastModificationTime;
entity.LastModifierId = userId;
// 若实体来自 AsNoTracking 查询(非 tracked),需显式附加并标记为 Modified
var entry = _dbContext.Entry(entity);
if (entry.State == EntityState.Detached)
{
@@ -262,11 +261,9 @@ public class CloudTaskSyncService
entry.State = EntityState.Modified;
}
}
// 否则保持服务端版本不变,不做任何操作
}
else
{
// 确认不存在,新建任务
entity = new TaskEntity
{
Id = upsert.Id ?? Guid.NewGuid(),
@@ -286,17 +283,14 @@ public class CloudTaskSyncService
if (shouldAdd && entity != null)
{
// 防御性检查:确保实体 Id 未被 ChangeTracker 跟踪,防止 UNIQUE 约束冲突
var trackedEntry = _dbContext.ChangeTracker.Entries<TaskEntity>()
.FirstOrDefault(e => e.Entity.Id == entity.Id);
if (trackedEntry != null)
{
// 实体已被跟踪(可能来自并发操作或其他路径),使用已跟踪的实体
entity = trackedEntry.Entity;
if (trackedEntry.State != EntityState.Added)
{
// 仅更新非新增实体的字段
entity.Title = (upsert.Title ?? string.Empty).Trim();
entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted;
@@ -312,7 +306,6 @@ public class CloudTaskSyncService
_dbContext.Tasks.Add(entity);
}
// 立即更新 existingTasks,避免同一批次内重复插入相同 Id
existingTasks[entity.Id] = entity;
if (upsert.Id.HasValue)
@@ -322,9 +315,6 @@ public class CloudTaskSyncService
}
}
/// <summary>
/// 将实体映射到 DTO。
/// </summary>
private static CloudTaskItem MapToItem(TaskEntity task)
{
return new CloudTaskItem
@@ -344,4 +334,13 @@ public class CloudTaskSyncService
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.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.Services.CloudSync.Services;
/// <summary>
/// 安全策略服务(按用户隔离)。
/// </summary>
public class SecurityPolicyService
public class SecurityPolicyService : ISecurityPolicyService
{
private readonly TodoDbContext _dbContext;
private readonly ICurrentUserAccessor? _currentUserAccessor;
/// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext)
/// <param name="currentUserAccessor">HTTP 上下文访问器(可选,用于从 HttpContext 获取当前用户 ID)。</param>
public SecurityPolicyService(TodoDbContext dbContext, ICurrentUserAccessor? currentUserAccessor = null)
{
_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>
@@ -76,4 +98,14 @@ public class SecurityPolicyService
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.HttpApi.Interfaces;
namespace Hua.Todo.Application.Interfaces;
namespace Hua.Todo.Application.Services.Interfaces;
/// <summary>
/// 任务管理服务接口
@@ -18,7 +19,7 @@ public interface ITaskService : IDynamicApiService
/// </summary>
/// <param name="id">任务唯一标识符</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(int id);
Task<TaskDto?> GetTaskByIdAsync(Guid id);
/// <summary>
/// 获取未完成的任务
@@ -51,18 +52,18 @@ public interface ITaskService : IDynamicApiService
/// </summary>
/// <param name="id">任务唯一标识符</param>
/// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(int id);
Task<TaskDto> ToggleCompleteAsync(Guid id);
/// <summary>
/// 删除任务
/// </summary>
/// <param name="id">要删除的任务唯一标识符</param>
Task DeleteTaskAsync(int id);
Task DeleteTaskAsync(Guid id);
/// <summary>
/// 获取子任务列表
/// </summary>
/// <param name="parentTaskId">父任务唯一标识符</param>
/// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId);
}

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