Compare commits

5 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
ShaoHua 9223ceca50 feat(mcp): 新增 MCP 服务基础设施,重构规则文件序号,新增 v1.3.0 工单文档
- 规则重组:全局/ 下 8 个规则合并为 6 个(01+02→01,05+06→04),序号顺延

- 新增项目规则 05-多入口功能同步规范(UI/语音入口覆盖检查)

- 新增 MCP 服务基础设施:Mcp/ 目录(DI 注册、端点扩展、动态工具描述符)、单元测试

- v1.3.0 工单文档:03 系列(会议任务拆分)、04(富文本描述与附件管理)

- MCP 接口与前端集成指南:docs/manual/08、09
2026-06-16 01:15:40 +08:00
ShaoHua aacc56e952 docs: 修正AI沟通记录规范为按主题归档,合并同主题多轮对话 2026-06-16 00:26:34 +08:00
ShaoHua 8b0b2cb197 docs: 新增AI沟通记录规范,清理旧规则文件引用
- 新增 08-AI沟通记录规范.md,约定沟通记录存储结构与生命周期

- 新增 docs/AI沟通记录/ 目录及首批沟通记录

- 删除已迁移的旧规则文件 commenting.md、documentation_sync.md

- 更新索引与智能体记忆文件
2026-06-16 00:22:59 +08:00
232 changed files with 17298 additions and 4531 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 @@
{}
-33
View File
@@ -1,33 +0,0 @@
---
alwaysApply: true
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
---
# 注释规范(必须遵守)
## 通用
- 新增或修改的代码必须包含足够注释,使“不了解该模块的人”也能理解其职责、边界与关键决策。
- 优先使用 **XML 文档注释**`///`),而不是随意的行内注释。
- 不允许无意义注释(例如“初始化变量”“进入方法”)。注释必须解释“为什么/约束/边界/副作用”。
- 不允许出现“TODO/FIXME”但无上下文或无处理方案的注释。
## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途
- 对关键参数/返回值:`param` / `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
-**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
-**异步/后台任务**
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
-**安全/隐私**
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
## TypeScript / Vue(前端)
- 对导出的函数/类型必须有注释,解释用途与输入输出。
- 对“与后端/MAUI 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
-34
View File
@@ -1,34 +0,0 @@
---
alwaysApply: true
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
---
# 文档同步规范(必须遵守)
## 通用原则
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
- **协作友好(局部修改)**:当并行处理多个任务/需求时,更新文档应尽量只修改与本任务直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
## 更新范围
- **README.md**
- 如果变更涉及核心功能点(Features)、安装步骤(Installation)、快速开始(Quick Start)或 API 端点(API Endpoints),必须同步更新。
- 变更涉及技术栈调整或项目结构变化时需更新。
- **docs/ 目录文档**
- **接口变更**:若修改了 API,需同步更新 [技术设计文档](docs/技术设计文档.md) 中的接口部分。
- **功能新增/调整**:需在 [产品需求文档](docs/产品需求文档.md) 和 [技术栈与模块](docs/技术栈与模块.md) 中体现。
- **架构/模式变更**:需更新 [技术设计文档](docs/技术设计文档.md)。
- **代码规范**:若引入了新的编码模式或工具,需更新 [代码规范文档](docs/代码规范文档.md)。
- **版本记录**:所有非琐碎的变更必须在 [版本记录.md](docs/版本记录.md) 中添加记录。
## 检查清单
1. [ ] 是否有新增的 API 端点?(更新 README 和技术设计文档)
2. [ ] 是否修改了现有的业务逻辑或数据结构?(更新技术设计文档)
3. [ ] 是否有新增的功能模块?(更新产品需求文档和技术栈说明)
4. [ ] 是否调整了开发环境或依赖?(更新 README)
5. [ ] 是否在 [版本记录.md](docs/版本记录.md) 中记录了本次变更?
6. [ ] 文档变更是否保持“局部修改”,只影响与本任务相关的段落/小节?(避免无关重排/改写)
@@ -1,17 +1,46 @@
# 智能体记忆与规范同步规则(必须遵守)
# 智能体记忆与存储规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(语义层面跨项目可复用),关注智能体如何维护记忆与同步规范,与具体项目业务无关。
> 适用范围:本规则属于 **全局规则**(语义层面跨项目可复用),关注智能体如何维护记忆、存储与同步规范,与具体项目业务无关。
>
> `.trae/` 整体目录结构与各子目录职责见 [.trae/索引.md](../../索引.md),本文件不再重复描述。
## 记忆存储
## 一、记忆存储
### 1.1 存储位置与组织
- 智能体的记忆必须存放在 `.trae/memory` 文件夹中
- 记忆应按对话日期或主题进行组织,便于后续查询和参考
- 记忆内容应包含对话历史、关键决策、重要代码片段和规范调整等信息
- **项目即时状态**(当前实现到哪一步、未完结事项、临时决策快照)应同步写入 `.trae/rules/项目/04-即时状态记忆.md`,便于其他智能体或开发者快速对齐
## 规范同步
### 1.2 内容规范
- 记忆文件应保持简洁明了,重点记录重要的开发决策和规范变更
- 避免存储冗余信息,只记录对项目有价值的内容
- 定期清理过时的记忆文件,保持存储空间的合理使用
### 1.3 与"项目即时状态"的边界
- `.trae/memory/` 偏向**长期保留**的对话产物与决策记录
- `.trae/rules/项目/04-即时状态记忆.md` 偏向**当前快照**(实现进度、未完结事项),更新频率高
- 二者不要重复存放同一份信息;以"是否需要长期沉淀"为判定标准
### 1.4 memory/ 生命周期
研发工单验收完成后,对 `memory/` 的处理遵循以下原则:
- **追加而非覆盖**:将本次工单中产生的、值得**长期沉淀**的内容(架构决策、关键避坑经验、引入的新依赖与版本)追加到对应文件
- **不存放过程信息**:实现进度、待办勾选、临时决策这些短期信息应留在 [04-即时状态记忆.md](../项目/04-即时状态记忆.md),不进 `memory/`
- **不删长期内容**:除非内容已被证伪或过时,否则不删除既有条目;过时内容用"已废弃 / 已被 XX 取代"的形式保留语义而非物理删除
- **新增文件序号化**:当主题足够独立时新建 `NN-名称.md`(序号紧接当前最大值),并在 [.trae/索引.md](../../索引.md) 的 `memory/` 章节同步追加链接
### 1.5 访问权限
- 记忆文件仅供开发团队内部参考使用
- 确保记忆文件中的敏感信息得到适当保护
- 遵循项目的版本控制和代码管理规范
## 二、规范同步
- 每次对话中涉及到的语法或规范相关内容,必须同步整理到 `.trae/rules` 目录下的对应文件中
- **通用规范**(注释、文档同步、工单流程、并行冲突)→ 写入 `.trae/rules/全局/`
@@ -19,11 +48,11 @@
- 若涉及到新的规范或规则,应创建新的规则文件进行记录
- 规范同步应及时、准确,确保规则文件能真实反映当前项目的编码规范和最佳实践
## 文件命名规则(强制)
## 三、文件命名规则(强制)
`.trae/` 下所有子目录中**新增的文件必须沿用 `NN-名称.md` 序号格式**,否则视为不合规:
- **格式**:两位数字 + 连字符 + 中文/英文名称 + `.md`,例如 `08-XXX规范.md`
- **格式**:两位数字 + 连字符 + 中文/英文名称 + `.md`,例如 `06-XXX规范.md`
- **序号取值**:紧接当前目录已有最大序号 +1,不得跳号、不得重复
- **入口/索引文件例外**`.trae/索引.md` 这类目录入口文件不带序号
- **重排禁止**:除非整体重构,否则不得重排已有文件的序号;新增只能追加在末尾
@@ -35,17 +64,17 @@
| 子目录 | 当前最大序号 | 下一个可用 |
|---|---|---|
| `rules/全局/` | 07 | 08 |
| `rules/项目/` | 04 | 05 |
| `rules/项目/` | 05 | 06 |
| `memory/` | 01 | 02 |
| `coordination/` | 02 | 03 |
## 实现要求
## 四、实现要求
- 智能体应定期检查并更新规则文件,确保其与项目实际情况保持一致
- 当发现规范冲突或需要调整时,应及时记录并通知相关人员
- 记忆存储和规范同步应作为智能体的核心功能,贯穿于整个开发过程
## 路径规范
## 五、路径规范
- 所有 Markdown 文档中不应使用绝对路径,应使用相对路径
- 相对路径应以项目根目录为基准,例如 `.trae/memory` 而非绝对路径
@@ -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`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。
@@ -1,33 +0,0 @@
# 记忆存储规范
> 适用范围:本规则属于 **全局规则**(跨项目通用),约束 `.trae/memory/` 的使用方式。
## 存储结构
- 记忆文件应存放在 `.trae/memory` 文件夹中
- 避免使用与日期相关的文件名,使用通用的描述性文件名
- 文件命名采用 `NN-名称.md` 序号格式
- 记忆内容应包含对话历史、关键决策、重要代码片段和规范调整等信息
## 内容规范
- 记忆文件应保持简洁明了,重点记录重要的开发决策和规范变更
- 避免存储冗余信息,只记录对项目有价值的内容
- 定期清理过时的记忆文件,保持存储空间的合理使用
## 与"项目即时状态"的边界
- `.trae/memory/` 偏向**长期保留**的对话产物与决策记录
- `.trae/rules/项目/04-即时状态记忆.md` 偏向**当前快照**(实现进度、未完结事项),更新频率高
- 二者不要重复存放同一份信息;以"是否需要长期沉淀"为判定标准
## memory/ 生命周期
研发工单验收完成后,对 `memory/` 的处理遵循以下原则:
- **追加而非覆盖**:将本次工单中产生的、值得**长期沉淀**的内容(架构决策、关键避坑经验、引入的新依赖与版本)追加到对应文件
- **不存放过程信息**:实现进度、待办勾选、临时决策这些短期信息应留在 [04-即时状态记忆.md](../项目/04-即时状态记忆.md),不进 `memory/`
- **不删长期内容**:除非内容已被证伪或过时,否则不删除既有条目;过时内容用"已废弃 / 已被 XX 取代"的形式保留语义而非物理删除
- **新增文件序号化**:当主题足够独立时新建 `NN-名称.md`(序号紧接当前最大值),并在 [.trae/索引.md](../../索引.md) 的 `memory/` 章节同步追加链接
## 访问权限
- 记忆文件仅供开发团队内部参考使用
- 确保记忆文件中的敏感信息得到适当保护
- 遵循项目的版本控制和代码管理规范
@@ -14,7 +14,7 @@ description: 强制文档同步规范:每次变更代码(如新增功能、
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
- **协作友好(局部修改)**:当并行处理多个研发工单/需求时,更新文档应尽量只修改与本工单直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
> 术语澄清:本规范中"研发工单"指编码工作项;项目业务里的"任务/Todo 待办项"是用户域实体,二者不要混淆。详见 [05-研发工单规则.md](./05-研发工单规则.md)。
> 术语澄清:本规范中"研发工单"指编码工作项;项目业务里的"任务/Todo 待办项"是用户域实体,二者不要混淆。详见 [04-研发工单全流程规范.md](./04-研发工单全流程规范.md)。
## 更新范围
@@ -0,0 +1,143 @@
# 研发工单全流程规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用),覆盖研发工单从术语定义、拆分输出到新增约束的全流程。
> ⚠️ 术语澄清(必读)
>
> 本项目存在两类"任务"概念,必须严格区分:
>
> | 术语 | 含义 | 适用范围 |
> |---|---|---|
> | **研发工单(Dev Work Item** | 智能体 / 开发者执行的**编码工作项** | 本规则的全部内容 |
> | **Todo 待办项(Todo Item** | Hua.Todo 项目**业务领域**中用户创建的待办事项 | 业务代码、产品文档 |
>
> 在代码、文档与对话中,凡涉及编码侧拆分时,**必须使用"研发工单"或"工单"**,禁止使用"任务"二字。
> 业务侧 `Task` / `SubTask` 等代码标识符**保持不变**(已固化于 API、DB、UI)。
---
## 一、研发工单拆分规范
### 1.1 适用时机
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出**研发工单拆分文档**
### 1.2 输出要求
1. **先读完所有相关文档**:包括 `docs/``docs/project/` 下与本次需求相关的内容
2. **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据
3. **新增专属文件夹**:在 `docs/project` 下新建 `研发工单-<主题>-<日期或版本>` 文件夹
4. **可并行工单拆分**:能同步执行的工单必须拆到不同 Markdown 文件中
5. **文件带序号**:按执行顺序编号(`01-xxx.md``02-xxx.md`
### 1.3 每个研发工单文件必须包含
- 目标 / 范围(做什么、不做什么)
- 前置条件(依赖哪些结论 / 接口 / 文档)
- 验收标准(可执行的验证点)
- 风险与回滚(如有)
### 1.4 子工单完成标记要求
- 子工单完成后,必须在 `00-工单总览.md` 中标注"已完成"
- 维护"待验证表",记录每个子工单的"待验证 / 已验证"状态
### 1.5 并行冲突规避要求
当工单会被分发到多个 solo 窗口并行推进时,每个研发工单文件必须额外包含 Touch List、共享文件策略与编译绿线策略。详细规约见 [05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md)。
### 1.6 推荐结构
```
docs/project/研发工单-<主题>-<版本>/
├── 00-工单总览.md # 背景、目标、关键决策、并行分组、待验证表
├── 01-并行工单A.md
├── 02-并行工单B.md
└── 03-串行工单C.md
```
---
## 二、新增工单约束
> 核心原则:**新增工单时,不得修改、覆盖、重排、删除任何已有工单文件。**
### 2.1 已有文件不可触碰
| 操作 | 是否允许 | 说明 |
|---|---|---|
| 修改已有工单的 `.md` 内容 | ❌ 禁止 | 即使发现格式、措辞可优化 |
| 重命名已有工单文件 | ❌ 禁止 | |
| 删除已有工单文件 | ❌ 禁止 | |
| 重排已有工单的序号 | ❌ 禁止 | 除非用户明确要求整体重构 |
| 修改 `00-工单总览.md` 中已有条目 | ❌ 禁止 | 只能追加新条目 |
| 在 `00-工单总览.md` 中追加新条目 | ✅ 允许 | |
| 修改 `.trae/rules/项目/04-即时状态记忆.md` 中已有快照行 | ❌ 禁止 | 只能追加新版本行 |
| 新增章节到 `04-即时状态记忆.md` | ✅ 允许 | |
### 2.2 子工单拆分格式
```
NN-NN-标题.md
```
- 前两位:主工单序号,后两位:子工单序号
- 示例:`03-01-会议数据模型与API.md`
### 2.3 新增工单的序号确定
1. 列出目标文件夹中已有文件
2. 找出最大主序号
3. 新增工单的主序号 = 最大主序号 + 1
4. 子工单的子序号从 `01` 开始递增
### 2.4 可追加修改的文件(例外)
| 文件 | 允许 | 不允许 |
|---|---|---|
| `00-工单总览.md` | 追加新条目 | 改写已有条目 |
| `.trae/rules/项目/04-即时状态记忆.md` | 新增版本章节 | 修改已有快照行 |
| `.trae/索引.md` | 追加新规则链接 | 修改已有条目 |
---
## 三、与业务侧 Todo 待办项的边界
- 代码、注释、提交信息中描述**编码工作**时:使用"研发工单 / 工单 / 子工单"
- 代码、注释、提交信息中描述**业务功能**时:使用"Todo 待办项 / Todo Item / 父子任务(业务实体)"
- 文档命名前缀:
- 编码侧:`研发工单-<主题>-<版本>/`
- 业务侧:遵循 `docs/` 既有命名习惯
- 提交信息示例:
-`feat(todo): 新增 Todo 待办项截止日期字段(研发工单 02-后端模型)`
-`feat: 完成任务 02`"任务"歧义)
---
## 四、检查清单
### 研发工单拆分检查
1. [ ] 是否已阅读完所有相关文档?
2. [ ] 是否在 `docs/project` 下新建了专属文件夹(命名以"研发工单-"开头)?
3. [ ] 是否产出 `00-工单总览.md`
4. [ ] 是否将可并行工单拆分为不同 md 文件?
5. [ ] 是否所有 md 文件都带有连续序号?
6. [ ] 子工单完成后是否在总览中标注"已完成"并更新待验证表?
### 新增工单检查
1. [ ] 新增工单的序号是否为当前最大主序号 + 1?
2. [ ] 子工单是否使用了 `NN-NN-标题.md` 格式?
3. [ ] 是否**未修改**任何已有工单文件的内容?
4. [ ] 是否**未修改** `00-工单总览.md``04-即时状态记忆.md` 中已有条目?
### 并行冲突检查
> 详见 [05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md#最小检查清单)。
---
**关联规则**[05-并行窗口冲突规约.md](./05-并行窗口冲突规约.md)、[07-代码实现与测试先行规范.md](./07-代码实现与测试先行规范.md)
> 工单进入代码实现阶段时,必须遵循"测试先行(ATDD)"工作流:先依据本规范的验收标准编写验收测试用例,再实现代码,测试全部通过后才算工单完成。详见 [07-代码实现与测试先行规范.md](./07-代码实现与测试先行规范.md)。
@@ -7,7 +7,7 @@ description:
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
> 详见 [05-研发工单规则.md](./05-研发工单规则.md)。
> 详见 [04-研发工单全流程规范.md](./04-研发工单全流程规范.md)。
> 凡涉及编码侧拆分时,**必须使用「研发工单」或「工单」**,禁止使用「任务」二字以避免与 Todo 待办项混淆。
## 适用范围
-129
View File
@@ -1,129 +0,0 @@
# 研发工单同步规则汇总(Dev Work Item Rules
> 适用范围:本规则属于 **全局规则**(跨项目通用),关注智能体如何拆分编码工作。
> ⚠️ 术语澄清(必读)
>
> 本项目存在两类"任务"概念,必须严格区分,避免命名混淆:
>
> | 术语 | 含义 | 适用范围 |
> |---|---|---|
> | **研发工单(Dev Work Item** | 智能体 / 开发者执行的**编码工作项**(拆分需求、并行开发、集成等) | 本规则文档的全部内容 |
> | **Todo 待办项(Todo Item** | Hua.Todo 项目**业务领域**中用户创建的待办事项(数据库实体、API 资源、UI 列表项) | 业务代码、产品需求文档、技术设计文档 |
>
> 本文档中所有"研发工单 / 工单 / 子工单"均指**编码工作项**,与业务侧的 Todo 待办项无关。
> 在代码、文档与对话中,凡涉及编码侧拆分时,**必须使用"研发工单"或"工单"**,禁止再使用"任务"二字以避免与 Todo 待办项混淆。
>
> 业务侧由于历史原因仍保留 `Task` / `SubTask` 等代码标识符(API、实体、UI),这些属于 Todo 待办项语义,**不在本规范替换范围内**。
>
> 本汇总文件是 [06-研发工单拆分规范.md](./06-研发工单拆分规范.md) 与 [07-并行窗口冲突规约.md](./07-并行窗口冲突规约.md) 的对外索引,详细规则以这两份源文件为准。
---
## 一、研发工单拆分规范
### 适用时机
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出**研发工单拆分文档**
### 输出要求
1. **先读完所有相关文档**:包括 `docs/``docs/project/` 下与本次需求相关的内容
2. **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据
3. **新增专属文件夹**:在 `docs/project` 下新建 `研发工单-<主题>-<日期或版本>` 文件夹
4. **可并行工单拆分**:能同步执行的工单必须拆到不同 Markdown 文件中
5. **文件带序号**:按执行顺序编号(`01-xxx.md``02-xxx.md`
### 每个研发工单文件必须包含
- 目标 / 范围(做什么、不做什么)
- 前置条件(依赖哪些结论 / 接口 / 文档)
- 验收标准(可执行的验证点)
- 风险与回滚(如有)
### 子工单完成标记要求
- 子工单完成后,必须在 `00-工单总览.md` 中标注"已完成"
- 维护"待验证表",记录每个子工单的"待验证 / 已验证"状态
---
## 二、并行窗口冲突规约
### 核心原则
1. **先声明后修改**:修改前先声明 Touch List 与共享文件策略
2. **文件所有权唯一**:同一时段内一个文件只能由一个窗口修改
3. **共享文件单点修改**:高耦合 / 共享入口的改动集中到集成窗口完成
4. **绿线优先**:任何可落盘的变更必须保持可编译
### Touch List 要求
- 精确到文件路径
- 标注修改类型(新增 / 小改 / 重构 / 接口变更 / 配置变更)
- 标注是否为共享文件
- 使用相对路径:`src\<module>\<file>`
### 共享文件判定标准(满足其一即为共享)
- 项目入口 / 启动逻辑、依赖注入注册、全局路由
- 公共配置、公共协议与 DTO、公共组件 / 样式
- 解决方案文件(`.sln``.csproj`)、锁文件、全局配置
### Writer 约束
- 非 Writer 窗口不得编辑共享文件
- 非 Writer 只能提供"差异建议"给 Writer 落盘
### 协调目录
固定目录:`.trae\coordination\`
- `01-ownership.md`:文件所有权登记表
- `02-shared-files.md`:共享文件清单(集成窗口维护)
- `handoff\`:差异建议 / 交接说明
- `wip\`:编译中途状态说明
### 编译绿线规则
- 不得提交破坏编译的变更
- 临时隔离手段(按优先级):
1. 新功能先放在新文件中,不在入口路径启用
2. 通过显式开关控制,默认关闭
3. 通过依赖注入分支或特性开关隔离
- 接口演进采用"双写 / 兼容期"策略
---
## 三、推荐文档结构
```
docs/project/研发工单-<主题>-<版本>/
├── 00-工单总览.md # 背景、目标、关键决策、并行分组、待验证表
├── 01-并行工单A.md
├── 02-并行工单B.md
└── 03-串行工单C.md
```
> 注意:上述目录与文件名中的"工单"指**研发工单**,与业务侧 Todo 待办项无关。
---
## 四、检查清单
### 研发工单拆分检查
1. [ ] 是否已阅读完所有相关文档?
2. [ ] 是否在 `docs/project` 下新建了专属文件夹(命名以"研发工单-"开头)?
3. [ ] 是否产出 `00-工单总览.md`
4. [ ] 是否将可并行工单拆分为不同 md 文件?
5. [ ] 是否所有 md 文件都带有连续序号?
6. [ ] 子工单完成后是否在总览中标注"已完成"并更新待验证表?
### 并行冲突检查
1. [ ] 每个研发工单 md 是否已写 Touch List(精确到文件)?
2. [ ] Touch List 中的共享文件是否指定了唯一 Writer?
3. [ ] 是否避免了对共享文件的无意义格式化 / 重排?
4. [ ] 当前改动是否保持可编译(绿线)?
5. [ ] 若涉及接口演进,是否采用兼容期策略?
---
## 五、与业务侧 Todo 待办项的边界
- 代码、注释、提交信息中描述**编码工作**时:使用"研发工单 / 工单 / 子工单"
- 代码、注释、提交信息中描述**业务功能**时:使用"Todo 待办项 / Todo Item / 父子任务(业务实体)"
- 文档命名前缀:
- 编码侧:`研发工单-<主题>-<版本>/`
- 业务侧(如有):遵循 `docs/` 既有命名习惯,禁止使用"研发工单"前缀
- 提交信息示例:
-`feat(todo): 新增 Todo 待办项截止日期字段(研发工单 02-后端模型)`
-`feat: 完成任务 02`"任务"歧义,禁用)
@@ -0,0 +1,79 @@
# AI沟通记录规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用),约束用户与智能体之间沟通记录的存储与管理。
## 一、存储结构
- AI沟通记录存放在 `docs/AI沟通记录/` 目录下
- **按主题归档**:同一主题的多轮对话追加在同一个 `NN-主题摘要.md` 文件中,只有新主题才新建文件
- 文件命名格式:`NN-主题摘要.md``NN` 为两位序号)
- 序号从 `01` 开始,每次新建递增 +1,不得跳号或重复
- 智能体在新建沟通记录时,默认使用当前最大序号 +1
- 当对话属于已有主题时,应在对应文件的"详细记录"章节追加子主题,而非新建文件
## 二、目录结构
```
docs/AI沟通记录/
├── 01-首次项目分析.md
├── 02-架构讨论.md
└── NN-主题摘要.md
```
- 每个文件即一次对话的完整记录
- 如有附件或补充材料,可在同目录下新建 `NN-主题摘要/` 子文件夹存放
## 三、记录内容规范
每个沟通记录文件必须包含以下结构:
```markdown
# AI沟通记录:NN-主题摘要
- **日期**YYYY-MM-DD
- **参与者**:用户、AI 助手
- **会话序号**NN
## 讨论主题
[简要描述本次对话的核心议题]
## 关键决策
[记录对话中达成的重要决策]
## 待办事项
[对话中确认的后续行动项]
## 详细记录
### NN-子主题1
[内容]
### NN-子主题2
[内容]
```
- **详细记录中的子主题必须带序号前缀**,格式为 `### NN-子主题名称``NN` 为两位序号,从 01 开始)
- 子主题序号用于精确引用,例如"沟通记录 03 的 02-xxx"
## 四、序号管理与引用
- 智能体在对话中可通过序号引用历史沟通记录,例如"参见沟通记录 03"
- 用户也可通过序号要求智能体回顾某次对话内容
- 序号一旦分配不可变更,即使删除了某个记录文件夹,也不得复用其序号
## 五、与记忆系统的边界
| 系统 | 用途 | 更新频率 |
|---|---|---|
| `docs/AI沟通记录/` | 对话过程与决策的完整记录 | 每次对话新建 |
| `.trae/memory/` | 长期沉淀的关键决策与经验 | 按需追加 |
| `.trae/rules/项目/04-即时状态记忆.md` | 当前快照(进度、临时决策) | 高频更新 |
- 三者不要重复存放同一份信息
- `docs/AI沟通记录/` 偏向**对话过程的完整存档**
- `.trae/memory/` 偏向**长期沉淀的提炼结论**
## 六、检查清单
1. [ ] 新建沟通记录时,序号是否为当前最大值 +1?
2. [ ] 文件名是否为 `NN-主题摘要.md` 格式?
3. [ ] 记录内容是否包含日期、参与者、讨论主题等必要字段?
4. [ ] 是否与 `.trae/memory/` 和即时状态记忆避免重复存储?
@@ -1,57 +0,0 @@
---
alwaysApply: false
---
# 研发工单拆分输出规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
> 详见 [05-研发工单规则.md](./05-研发工单规则.md)。
> 凡涉及编码侧拆分时,**必须使用「研发工单」或「工单」**,禁止使用「任务」二字以避免与 Todo 待办项混淆。
## 适用时机
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出研发工单拆分文档,再开始写代码或改配置。
## 输出要求
- **先读完所有相关文档**:包括但不限于 `docs/``docs/project/` 下与本次需求相关的内容。
- **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据。
- **新增一个专属文件夹**:在 `docs/project` 下新建一个文件夹存放本次研发工单拆分文档。
- 文件夹命名建议:`研发工单-<主题>-<日期或版本>`(保持可检索、避免与既有文档冲突)。
- **可并行的工单要拆成不同 md**:能同步执行(相互无依赖/弱依赖)的工单,必须拆到不同的 Markdown 文件中,便于并行推进与分工。
- **文件必须带序号**:同一文件夹下的 md 文件按执行顺序编号,序号从小到大。
- 文件名建议:`01-xxx.md``02-xxx.md``03-xxx.md`
- **每个研发工单文件至少包含**
- 目标/范围(做什么、不做什么)
- 前置条件(依赖哪些结论/接口/文档)
- 验收标准(怎么判断完成,包含可执行的验证点)
- 风险与回滚(如有)
- **子工单完成后的标记要求**
- 当任一子工单(例如 `01-*`/`02-*`/`03-*`)完成实现后,必须在对应版本的 `00-工单总览.md` 中同步标注"已完成"。
- 同时必须维护一张"待验证表"(可用 Markdown 表格),对每个子工单给出"待验证/已验证"状态,避免实现完成但验收未闭环。
- **并行冲突规避要求**:当工单会被分发到多个 solo 窗口并行推进时,每个研发工单文件必须额外包含:
- 触碰文件清单(Touch List,精确到文件)
- 共享文件策略(哪些是共享文件、唯一 Writer 是谁、如何与集成窗口对接)
- 编译绿线策略(如何确保阶段性交付不破坏编译)
## 推荐结构(模板)
- `00-工单总览.md`
- 背景与目标
- 关键决策与约束
- 并行分组说明(哪些文件可同步做)
- 待验证表(每个子工单的"待验证/已验证"状态)
- `01-<并行工单A>.md`
- `02-<并行工单B>.md`
- `03-<串行工单C>.md`
## 最小检查清单
1. [ ] 是否确认已阅读完所有相关文档?
2. [ ] 是否在 `docs/project` 下新建了本次专属文件夹(命名以"研发工单-"开头)?
3. [ ] 是否产出 `00-工单总览.md`(或等价总览文件)?
4. [ ] 是否将可并行工单拆分为不同 md 文件?
5. [ ] 是否所有 md 文件都带有连续序号?
6. [ ] 并行工单是否为每个研发工单文件补充了 Touch List/共享文件策略/编译绿线策略?
7. [ ] 子工单完成后,是否在对应版本的 `00-工单总览.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.{模块名}`
- 文件组织:每个模块新建子目录,模块级共用测试放根目录
+2 -2
View File
@@ -3,7 +3,7 @@
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 本文档规定 **业务实体**(Todo 待办项相关)与 **编码工作项**(研发工单)在代码、文档、提交信息中的命名边界。
> 全局术语规则参见 [.trae/rules/全局/05-研发工单规则.md](../全局/05-研发工单规则.md)。
> 全局术语规则参见 [.trae/rules/全局/04-研发工单全流程规范.md](../全局/04-研发工单全流程规范.md)。
## 一、术语对照(核心)
@@ -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 不报错?
+100 -5
View File
@@ -11,8 +11,9 @@
## 一、当前活跃版本
- **进行中版本**v1.2.0
- **研发工单总览**[docs/project/研发工单-v1.2.0/00-工单总览.md](../../../docs/project/研发工单-v1.2.0/00-工单总览.md)
- **进行中版本**v1.2.0(收尾中)、v1.3.0(规划中)
- **v1.2.0 研发工单总览**[docs/project/研发工单-v1.2.0/00-工单总览.md](../../../docs/project/研发工单-v1.2.0/00-工单总览.md)
- **v1.3.0 研发工单总览**[docs/project/研发工单-v1.3.0/00-工单总览.md](../../../docs/project/研发工单-v1.3.0/00-工单总览.md)
- **PRD**[docs/project/产品需求文档-1.2.0.md](../../../docs/project/产品需求文档-1.2.0.md)
## 二、v1.2.0 工单状态快照
@@ -31,14 +32,27 @@
| 08 - cloud_sync 重构 | 已设计 | 待实现 | "同源 Host"方案,Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` |
| 09 - CloudSync 同步策略改进 | 已实现 | 待验证 | TaskEntity 继承 ABP 基类;软删除修复(SaveChangesAsync);前端类型和 cloudSync.ts 已更新;新增 guid.ts |
## 三、关键临时决策
## 三、v1.3.0 工单状态快照
| 子工单 | 实现状态 | 验证状态 | 简要说明 |
|---|---|---|---|
| 01 - HTTP 服务转换 MCP 服务 | 进行中 | 待验证 | 将现有 HTTP API 映射为 MCP 工具描述符 |
| 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 | 已完成 | 待验证 | MeetingBreakdownDialog.vue + meeting.ts 拆分/确认 API;待集成到 TaskItem |
| 04 | 富文本描述、附件与外部链接 | 已完成 | 待验证 | Description 字段 + AttachmentEntity 模型 + 附件 CRUD API + 外部链接 + 平台文件打开器(Maui/Avalonia+ 前端类型和 API 模块完善 + EF 迁移 + 19 个单元测试通过 |
## 四、关键临时决策
- **MAUI 端不暴露云同步端点**:`MauiProgram.cs` 仅注册 `AddApplicationServices()`,不调 `AddCloudSyncServer()`。云同步端点只在 `Hua.Todo.Host` 暴露。
- **本地用户 ID 固定为 `"local"`**:嵌入式模式下 `Tasks.UserId = TodoUserIds.LocalUserId`,与云端用户隔离逻辑共存而不冲突。
- **SQLite WAL 模式**:嵌入式宿主启动时强制开启 WAL,降低锁冲突。
- **数据库路径**:默认 `LocalApplicationData/Hua.Todo/Hua.Todo.db`(避免安装目录无写权限);Host 模式使用 `src/Hua.Todo.Host/Hua.Todo.db`(开发/测试)。
## 、已知未完结事项 / 待办
## 、已知未完结事项 / 待办
- [ ] 06 客户端"内存模式"在 `allowPersist=false` 时的端到端落盘清理(含 token、同步队列)尚未充分验证
- [ ] 06.1 设计中的 Admin 管理后台前端(位于 `Hua.Todo.Host/wwwroot/admin/`)当前仅有 `index.html` 占位,需 Vue 3 + Vite 实现
@@ -46,7 +60,36 @@
- [ ] 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):
- `.trae/rules/` 全部中文文件名 + 拆分为 `全局/``项目/` 两个子目录
@@ -56,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 个测试全部通过
---
@@ -0,0 +1,85 @@
# 多入口功能同步规范(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
---
## 一、背景
Hua.Todo 存在两个功能入口:
| 入口 | 位置 | 方式 |
|---|---|---|
| **UI 入口** | WebView / 前端界面(Hua.Todo.Web | 键盘/鼠标/触控交互 |
| **语音控制入口** | 语音指令(平台原生 STT + LLM 意图解析) | 语音输入 → 指令执行 |
v1.3.0 之前,功能开发只关注 UI 入口。v1.3.0 工单 02 落地后语音控制入口正式就绪,此后**所有新增功能必须在需求阶段同步确认两个入口的覆盖情况**。
---
## 二、核心规则
### 2.1 功能入口检查(强制)
每次新增功能(含新工单、新特性),智能体必须在需求讨论或工单拆分阶段执行以下检查:
| 检查项 | 说明 |
|---|---|
| **UI 入口** | 当前功能是否已有 UI 入口规划?入口在哪个页面/组件?交互方式是什么? |
| **语音控制入口** | 当前功能是否已有语音指令规划?对应哪个意图?参数是什么?LLM prompt 是否需要更新? |
### 2.2 缺失时为通知用户,不自行决策
- 任一入口缺失时,**智能体必须主动告知用户**,由用户决定:
1. 本次就做(补上缺失入口)
2. 本次不做(记录为已知缺口,后续版本补)
3. 不需要(该入口不适合此功能,如选项过多需可视化交互的操作)
- **智能体不得在用户未确认的情况下自行跳过或自行补充入口设计**
### 2.3 输出格式(检查表)
智能体在需求讨论阶段,必须输出以下检查表:
```markdown
## 功能入口覆盖检查
| 入口 | 已规划 | 方案 |
|---|---|---|
| UI | ✅ / ❌ | [描述 UI 入口] |
| 语音 | ✅ / ❌ | [描述语音指令与意图] |
> 不可覆盖的入口说明:[如"语音控制暂不支持 XX 操作(原因)"]
```
---
## 三、适用场景
### 3.1 必须检查的场景
- 新增业务功能(如新增双因素认证、新增标签系统)
- 新增 API 端点(如新增导出/导入)
- 新增 UI 页面/组件(如新增设置页、新增弹窗)
- 新工单拆分阶段
### 3.2 无需检查的场景
- 纯 bug 修复
- 纯性能优化(不改变用户可感知功能)
- 纯基础设施调整(如 CI/CD、打包脚本)
- 依赖升级
---
## 四、检查清单
1. [ ] 本次新增功能是否已确认 UI 入口?
2. [ ] 本次新增功能是否已确认语音控制入口?
3. [ ] 缺失入口是否已通知用户并记录决策?
4. [ ] LLM intent parser 的 prompt 是否需要同步更新(新增意图/新增参数)?
---
## 五、与工单 02 的关系
本规范的语音入口覆盖检查表来源于 [docs/project/研发工单-v1.3.0/02-语音通话与语音控制.md](../../../docs/project/研发工单-v1.3.0/02-语音通话与语音控制.md) 的第七章"与其他工单的语音入口衔接"。
+14 -12
View File
@@ -8,9 +8,9 @@
>
> 文件命名约定:每个子目录内文件以 `NN-名称.md` 格式编号(两位数字),编号反映**阅读优先级 / 依赖顺序**。索引文件本身不带序号。
>
> ⚠️ **新增文件必须遵守命名规则**:序号 = 当前目录最大值 + 1,不得跳号或重复,并需同步更新本索引文件中的对应章节。详见 [01-智能体记忆.md](./rules/全局/01-智能体记忆.md#文件命名规则强制)。
> ⚠️ **新增文件必须遵守命名规则**:序号 = 当前目录最大值 + 1,不得跳号或重复,并需同步更新本索引文件中的对应章节。详见 [01-记忆与存储规范.md](./rules/全局/01-记忆与存储规范.md#文件命名规则强制)。
>
> ⚠️ **任务完成后清理义务**:研发工单验收完成后,必须清理 `coordination/` 中的临时记录行(保留文件与表头),并按需追加 `memory/` 的长期记忆条目。详见 [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md#任务验收后-coordination-清理) 与 [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md#memory-生命周期)。
> ⚠️ **任务完成后清理义务**:研发工单验收完成后,必须清理 `coordination/` 中的临时记录行(保留文件与表头),并按需追加 `memory/` 的长期记忆条目。详见 [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md#任务验收后-coordination-清理) 与 [01-记忆存储规范.md](./rules/全局/01-记忆存储规范.md#14-memory-生命周期)。
---
@@ -32,13 +32,13 @@
| 文件 | 职责 |
|---|---|
| [01-智能体记忆.md](./rules/全局/01-智能体记忆.md) | 智能体记忆/规范同步机制(**不含目录总览,由本索引文件承担** |
| [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md) | `.trae/memory/` 的使用方式 |
| [03-注释规范.md](./rules/全局/03-注释规范.md) | C# / TypeScript / Vue 代码注释要求 |
| [04-文档同步规范.md](./rules/全局/04-文档同步规范.md) | 代码变更同步 README/docs 的硬性要求 |
| [05-研发工单规则.md](./rules/全局/05-研发工单规则.md) | 研发工单术语与边界(编码工作项 vs Todo 待办项) |
| [06-研发工单拆分规范.md](./rules/全局/06-研发工单拆分规范.md) | 工单拆分目录与文件结构 |
| [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 |
| [01-记忆与存储规范.md](./rules/全局/01-记忆与存储规范.md) | 智能体记忆/存储/规范同步机制 & `.trae/memory/` 使用方式 & 文件命名规则(含序号速查表 |
| [02-注释规范.md](./rules/全局/02-注释规范.md) | C# / TypeScript / Vue 代码注释要求 |
| [03-文档同步规范.md](./rules/全局/03-文档同步规范.md) | 代码变更同步 README/docs 的硬性要求 |
| [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) |
---
@@ -46,10 +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) | 测试项目目录结构、分层规则(禁止跨层)、技术栈与命名规范 |
---
@@ -57,7 +59,7 @@
- 用途:长期沉淀的对话产物、关键开发决策、不再频繁更新的项目快照
- 与"项目即时状态"的边界:[04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) 偏当前快照、更新频率高;`memory/` 偏长期保留
- 维护规约:详见 [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md)
- 维护规约:详见 [01-记忆存储规范.md](./rules/全局/01-记忆存储规范.md)
- 文件:
- [01-project_memory.md](./memory/01-project_memory.md):依赖小版本号、历史决策、变更时间轴
@@ -66,7 +68,7 @@
## coordination/ — 多窗口协作运行时目录
- 用途:并行 solo 窗口下的"谁在改什么"登记表与共享文件清单(**运行时数据,非冷文档**)
- 协作协议详见 [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md)
- 协作协议详见 [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md)
- 文件:
- [00-README.md](./coordination/00-README.md):目录约定
- [01-ownership.md](./coordination/01-ownership.md):文件/目录所有权(Writer)登记
+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,57 @@
# AI沟通记录:01-规则沟通记录体系建立
- **日期**2026-06-16
- **参与者**:用户、AI 助手
- **会话序号**01
## 讨论主题
建立 AI 沟通记录体系:在全局规则中新增规范,在 `docs/` 下建立存储目录。
## 关键决策
1. AI沟通记录规则文件编号为 `08-AI沟通记录规范.md`,存放在 `.trae/rules/全局/`
2. AI沟通记录存放在 `docs/AI沟通记录/` 目录下
3. 按主题归档:同一主题的多轮对话追加在同一个 `NN-主题摘要.md` 文件中,而非每轮对话新建文件
4. 新主题才新建文件,序号从 01 递增,不可复用
5. 详细记录中的子主题必须带序号前缀(如 `01-xxx``02-xxx`),便于精确引用
## 待办事项
- [x] 创建 `.trae/rules/全局/08-AI沟通记录规范.md`
- [x] 创建 `docs/AI沟通记录/01-规则沟通记录体系建立.md`
- [x] 更新 `.trae/索引.md` 同步新增内容
- [x] 更新 `.trae/rules/全局/01-智能体记忆.md` 中的序号速查表
## 详细记录
### 01-用户初始请求
用户提出了两个明确要求:
1. 将AI沟通记录相关规则添加到全局规则目录
2. 建立 `docs/` 下的AI沟通记录存储体系,带序号编号,方便后续引用
### 02-过程中的纠正
AI 助手初始方向偏离,误以为需要读取源代码进行分析。用户明确纠正:此项工作与源代码无关,应直接操作规则文件等内容。
### 03-命名修正
用户指出"通信记录"名称不准确,应改为"AI沟通记录"。随即统一更新了规则文件名、目录名、索引引用及记录内容中的所有措辞。
### 04-目录结构修正
用户指出当前的子文件夹结构(`NN-主题摘要/记录.md`)不正确,应改为直接的单文件结构(`NN-主题摘要.md`)。
同时文件夹名"规则与沟通记录体系建立"应去掉"与"字,改为"规则沟通记录体系建立"。
### 05-归档方式修正
用户指出沟通记录应按主题归档,而非按对话次数新建文件。同一主题的多轮对话应追加在同一个文件中,只有新主题才新建文件。
此前错误地创建了 `02-沟通记录目录结构修正.md`,这属于同一主题的后续对话,应合并到 `01-规则沟通记录体系建立.md` 中。
### 06-最终方案
- 全局规则文件:`.trae/rules/全局/08-AI沟通记录规范.md`
- AI沟通记录目录:`docs/AI沟通记录/`
- 记录格式:`NN-主题摘要.md`(按主题归档,同主题多轮对话追加在同一文件)
- 引用方式:通过序号引用,如"参见沟通记录 01"
@@ -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 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
@@ -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)
@@ -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` | 实体清单更新 | 是(规则) |
---
@@ -6,12 +6,13 @@
## 一、背景与目标
Hua.Todo v1.3.0 版本聚焦于个核心能力的升级:
Hua.Todo v1.3.0 版本聚焦于个核心能力的升级:
| 序号 | 能力 | 描述 |
|---|---|---|
| 1 | **MCP 服务映射** | 将现有 HTTP 服务转换为 MCPModel Context Protocol)服务,提升服务调用效率与可扩展性 |
| 2 | **语音交互** | 实现语音通话数据传输与语音控制功能,支持通过语音指令操作 Todo 待办项 |
| 2 | **语音控制与 AI 辅助** | 通过语音指令操作 Todo 待办项(CRUD + 子任务),通过 LLM 提供 AI 辅助任务拆分建议 |
| 3 | **会议任务拆分** | 以"会议"为入口记录会议内容(录音/文字),通过 AI 分析自动生成待办项拆分建议,用户确认后批量创建 |
---
@@ -20,11 +21,22 @@ Hua.Todo v1.3.0 版本聚焦于两个核心能力的升级:
### 2.1 并行工单(可同步执行)
| 工单编号 | 标题 | 负责人 | 状态 |
|---|---|---|---|
| 01 | HTTP 服务转换为 MCP 服务 | - | 待开始 |
| 02 | 语音通话与语音控制功能 | - | 待开始 |
|---|---|---|---|---|---|---|
| 01 | HTTP 服务转换为 MCP 服务 | - | 已完成 |
| 02 | 语音控制与 AI 辅助 | - | 已完成 |
| 03 | 会议任务拆分 | - | 待开始 |
| 04 | 富文本描述、附件与外部链接 | - | 已完成 |
### 2.2 串行工单(依赖前置工单完成)
### 2.2 03 子工单拆分
| 子工单 | 标题 | 依赖 | 状态 |
|---|---|---|---|
| 03-01 | 会议数据模型与 API | 无 | 待开始 |
| 03-02 | 音频录制与转写 | 03-01 | 待开始 |
| 03-03 | AI 任务拆分服务 | 03-01、工单 02 LlmClientService | 待开始 |
| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 已完成 |
### 2.3 串行工单(依赖前置工单完成)
当前版本暂无串行工单依赖。
@@ -45,19 +57,71 @@ Hua.Todo v1.3.0 版本聚焦于两个核心能力的升级:
- MCP 服务可正常对外提供接口
- 所有原有 HTTP API 功能在 MCP 服务中可正常使用
### 3.2 工单 02 - 语音通话与语音控制功能
### 3.2 工单 02 - 语音控制与 AI 辅助
**目标**:实现语音通话数据传输与语音控制功能
**目标**:实现语音控制 Todo 待办项能力与 AI 辅助任务拆分功能
**核心需求**
- 语音通话数据传输能力
- 语音指令识别与解析
- 语音控制 Todo 待办项操作(创建、编辑、删除、完成等
- 与现有业务系统对接
- 语音输入(STT):平台原生语音识别 → 文字
- 语音播报(TTS):执行结果语音反馈
- 语音指令解析与执行(CRUD + 子任务 + 歧义处理
- AI 辅助任务拆分(LLM 生成子任务建议,用户确认后批量创建)
**不包含**
- 语音通话功能(场景不明确,本期不做)
**验收标准**
- 语音通话功能可正常使用
- 语音控制可准确执行 Todo 业务操作
- 各平台 STT/TTS 可正常工作
- 语音指令可准确执行 Todo 业务操作
- 歧义场景返回候选列表而非直接执行
- AI 拆分建议需用户确认后才创建子任务
### 3.3 工单 03 - 会议任务拆分
**目标**:以"会议"为入口,通过录音/文字记录会议内容,AI 自动提取行动项生成待办项建议
**核心需求**
- 新增"会议"类型标记(TaskType.Meeting
- 录音:前端 MediaRecorder → 后端 STT 转写
- 文字:直接输入/粘贴会议纪要
- AI 会议拆分(LLM 分析会议内容 → 待办项建议列表)
- 建议审阅与确认 UI(勾选、编辑、批量创建子任务)
**不包含**
- 实时语音转写(本期不做)
- 音频持久存储(转写完成后删除音频)
**验收标准**
- 可通过"会议"类型创建待办项,显示会议图标
- 录音可正常录制并提交转写
- 文字纪要可保存/编辑
- AI 拆分返回 3-10 条结构化建议,含优先级和原因
- 用户可审阅、勾选、编辑建议后批量创建为子任务
- 离线模式拒绝 AI 拆分(提示降级)
### 3.4 工单 04 - 富文本描述、附件与外部链接
**目标**:为 Todo 待办项新增多行描述、文件附件管理与外部程序/链接启动能力(仅桌面端)
**核心需求**
- `TaskEntity` 新增 `Description` 多行描述字段
- 新增 `AttachmentEntity` 数据模型,支持本地文件上传、下载、删除
- 支持外部链接(URL)作为附件,点击在默认浏览器打开
- 桌面端通过系统关联程序打开本地附件(`Process.Start` / `xdg-open`
- 前端编辑对话框扩展描述 textarea + 附件管理区域
**不包含**
- 移动端附件管理(本期仅 Windows/Linux 桌面端)
- 附件云同步(后续版本规划)
- 附件预览(如图片缩略图,本期不做)
- 富文本编辑器(本期仅纯文本)
**验收标准**
- 描述字段可正常编辑和保存
- 附件可上传、下载、删除,文件完整性校验
- 外部链接可添加并在浏览器中打开
- 本地附件可通过系统关联程序打开
- 附件数量(20个)和大小(50MB)限制生效
---
@@ -65,12 +129,26 @@ Hua.Todo v1.3.0 版本聚焦于两个核心能力的升级:
| 子工单 | 验证项 | 状态 | 备注 |
|---|---|---|---|
| 01 | MCP 服务契约文档生成 | 验证 | - |
| 01 | MCP 服务可用性测试 | 验证 | - |
| 01 | 原有 API 功能兼容性 | 验证 | - |
| 02 | 语音通话连接测试 | 待验证 | - |
| 01 | MCP 服务契约文档生成 | 验证 | 动态工具自动生成,工具名/描述/参数 schema 均已覆盖 |
| 01 | MCP 服务可用性测试 | 验证 | 17 个单元测试全部通过(含工具调用端到端验证) |
| 01 | 原有 API 功能兼容性 | 验证 | 所有 ITaskService 方法均生成 MCP 工具,覆盖 CRUD 全部 9 个 API |
| 01 | CloudSync MCP 工具 | 已知缺口 | CloudSync 服务未实现 IDynamicApiService,需手动映射或重构 |
| 02 | STT/TTS 平台适配 | 待验证 | Windows 优先,其他平台后续 |
| 02 | 语音指令识别准确率 | 待验证 | - |
| 02 | Todo 业务操作覆盖度 | 待验证 | - |
| 02 | 歧义处理正确性 | 待验证 | - |
| 02 | AI 拆分建议质量 | 待验证 | - |
| 02 | Todo 业务操作覆盖度 | 待验证 | CRUD + 子任务 + 取消完成 |
| 03 | 会议数据模型迁移 | 待验证 | TaskType 字段 + DB 迁移 |
| 03 | 录音与转写链路 | 待验证 | 录制 → 上传 → 转写 → 保存 |
| 03 | AI 会议拆分质量 | 待验证 | 建议含标题+优先级+原因 |
| 03 | 建议审阅与批量创建 | 待验证 | MeetingBreakdownDialog 已实现:勾选/编辑/确认后创建子任务 |
| 04 | 描述字段编辑与保存 | 待验证 | - |
| 04 | 附件上传/下载/删除 | 待验证 | - |
| 04 | 外部链接添加与打开 | 待验证 | - |
| 04 | 本地文件通过系统程序打开 | 待验证 | Process.Start / xdg-open |
| 04 | 附件数量/大小限制 | 待验证 | 20 个 / 50MB |
| 04 | 待办项删除时附件级联清理 | 待验证 | - |
| 04 | 跨平台编辑安全(移动端不覆盖描述/附件) | 待验证 | 桌面设值 → 移动端改标题 → 桌面验证不丢失 |
---
@@ -79,8 +157,21 @@ Hua.Todo v1.3.0 版本聚焦于两个核心能力的升级:
| 决策点 | 结论 |
|---|---|
| MCP 框架选择 | 使用 TRAE 平台内置的 MCP 服务框架 |
| 语音识别方案 | 集成平台语音识别能力 |
| 服务注册方式 | 遵循平台标准注册流程 |
| STT/TTS 分层 | 接口在 Core,实现在各平台目录(同全局快捷键模式) |
| 语音指令解析 | A+C 混合方案:在线走 LLM 意图解析(LlmIntentParser),离线降级到规则匹配(RuleIntentParser),双策略通过 HybridVoiceIntentParser 自动切换 |
| 歧义处理策略 | 目标不唯一时返回候选列表;LLM confidence < 0.8 时触发确认;confidence < 0.5 按 UNKNOWN 处理 |
| AI 拆分安全性 | LLM 调用在 Host 端,建议需用户确认后才创建 |
| LLM 复用 | 意图解析与 AI 拆分共用同一 LLM 基础设施(LlmClientService |
| 语音通话 | 本期不做,场景不明确 |
| 会议类型 | 新增 `TaskType` 枚举区分普通待办项与会议,会议有专属录音/纪要/拆分 UI |
| 录音存储 | 转写完成后删除原始音频文件,节省空间 |
| 音频转写 | 优先服务端 Whisper API;后续补各平台原生 STT |
| LLM 拆分 prompt | 会议专用 prompt,侧重"提取行动项",输出优先级+原因 |
| 工单 02 复用 | `LlmClientService` 直接复用;会议拆分 prompt 独立于语音意图解析 |
| 多入口覆盖规则 | 后续每项新增功能必须在需求阶段确认 UI 入口 + 语音控制入口覆盖情况,详见 [05-多入口功能同步规范.md](../../../.trae/rules/项目/05-多入口功能同步规范.md) |
| 附件存储策略 | 附件存储在应用数据目录 `Attachments/` 子目录;外部链接不复制文件仅存 URL;单文件 50MB / 每待办项 20 个上限 |
| 外部程序启动 | 通过 `IPlatformAttachmentOpener` 接口实现平台差异:Windows 用 `Process.Start`Linux 用 `xdg-open` |
| 工单 04 与 03 共享文件 | `TaskEntity.cs``TodoDbContext.cs``task.ts``TaskEditDialog.vue` 为共享文件,工单 03 先写入,04 后续追加 |
---
@@ -89,8 +180,11 @@ Hua.Todo v1.3.0 版本聚焦于两个核心能力的升级:
| 依赖项 | 状态 | 来源 |
|---|---|---|
| TRAE MCP SDK | 已就绪 | 平台内置 |
| 语音识别服务 | 已就绪 | 平台内置 |
| 各平台原生 STT/TTS API | 已就绪 | 平台内置 |
| Hua.Todo v1.2.0 | 已完成 | 上一版本 |
| LLM APIAI 拆分) | 待确认 | Host 端调用 |
| 浏览器 MediaRecorder API | 已就绪 | 前端录音 |
| 工单 02 LlmClientService | 待实现 | 会议拆分复用 |
---
@@ -99,9 +193,15 @@ Hua.Todo v1.3.0 版本聚焦于两个核心能力的升级:
| 风险 | 影响 | 应对策略 |
|---|---|---|
| MCP 服务注册失败 | 无法对外提供服务 | 保留 HTTP API 作为降级方案 |
| 语音识别准确率不足 | 用户体验下降 | 提供文字输入作为备选方案 |
| 平台 STT 识别准确率不足 | 用户体验下降 | 提供文字输入作为备选方案 |
| Linux STT 可用性差 | Linux 语音控制不可用 | Web Speech API 降级;或 `vosk` 离线模型 |
| LLM API 不稳定 | AI 拆分功能不可用 | 功能降级,语音指令其他部分不受影响 |
| 会议录音文件过大 | 上传超时/存储压力 | 前端限制最长 2 小时;压缩音频格式 |
| 浏览器 MediaRecorder 兼容性 | 部分平台录音不可用 | 降级提示使用文字输入 |
| STT 转写准确率不足 | 会议纪要质量差 | 转写后支持用户编辑修正 |
---
**创建日期**2026-06-15
**修订日期**2026-06-16
**版本**v1.3.0
@@ -1,23 +1,25 @@
# 研发工单 v1.3.0 - 02 语音通话与语音控制功能
# 研发工单 v1.3.0 - 02 语音控制与 AI 辅助
---
## 一、目标与范围
### 1.1 目标
实现语音通话数据传输与语音控制功能,支持通过语音指令操作 Todo 待办项,提升用户交互体验
实现语音控制 Todo 待办项能力与 AI 辅助任务拆分功能,通过平台原生 STT/TTS 实现语音输入输出,通过 LLM 意图解析处理自然语言指令,离线降级到规则匹配,通过 LLM 提供 AI 拆分建议
### 1.2 范围
**包含**
- 语音通话数据传输能力
- 语音指令识别与解析
- 语音控制 Todo 待办项操作
- 与现有业务系统对接
- 语音输入(STT):平台原生语音识别 → 文字
- 语音播报(TTS):执行结果语音反馈
- 语音指令解析与执行(CRUD + 子任务操作
- 指令歧义处理(目标不唯一时返回候选列表)
- AI 辅助任务拆分(LLM 生成子任务建议,用户确认后批量创建)
**不包含**
- 语音通话 UI 界面设计(仅提供能力层
- 第三方语音服务集成(使用平台内置能力
- 语音通话功能(场景不明确,本期不做
- 前端语音 UI 设计(仅提供能力层与 API
- 第三方语音服务集成(使用平台原生能力)
---
@@ -26,109 +28,426 @@
| 条件 | 说明 |
|---|---|
| Hua.Todo v1.2.0 | 已完成,提供基础业务能力 |
| 平台语音服务 | 已就绪 |
| 工单 01(可选) | MCP 服务就绪后可通过 MCP 调用 |
| 平台原生 STT/TTS | Windows`Windows.Media.SpeechRecognition`/`SpeechSynthesis`)、Android`SpeechRecognizer`/`TextToSpeech`)、iOS/macOS`SFSpeechRecognizer`/`AVSpeechSynthesizer`)、LinuxWebKitGTK Web Speech API / `vosk` 离线模型) |
| LLM API | 在线模式意图解析 + AI 拆分共用;API Key 在 Host 端管理 |
| 工单 01(可选) | MCP 服务就绪后,语音指令与 AI 拆分也可通过 MCP 暴露 |
---
## 三、需求规格
## 三、架构设计
### 3.1 语音通话数据传输
**功能描述**:支持语音通话数据的实时传输
**接口设计**
```
工具名:startVoiceCall
参数:
- target: string - 通话目标标识
返回:
- callId: string - 通话 ID
- status: string - 通话状态(connected/disconnected
```
### 3.1 整体链路
```
工具名:endVoiceCall
参数:
- callId: string - 通话 ID
返回:
- success: boolean - 是否成功结束
平台 STT(语音→文字)
IVoiceInputServiceCore 接口,各平台实现)
↓ 回调文字到前端
WebView → POST /api/voice/command { text: "帮我把那个开会的删了吧" }
Host API → IVoiceIntentParser(双策略)
├─ 在线:LlmIntentParser(调 LLM,输出结构化意图+参数)
└─ 离线:RuleIntentParser(关键词规则匹配,覆盖高频指令)
意图 + 参数 → 判断歧义
├─ 无歧义 → 调用 TaskService 执行 → TTS 播报结果
├─ 有歧义 → 返回候选列表 → 前端展示 → 用户确认 → 再执行
└─ UNKNOWN → TTS 播报"没听懂,请再说一次"
```
### 3.2 语音指令控制
### 3.2 STT/TTS 分层策略
**功能描述**:支持通过语音指令操作 Todo 待办项
遵循与全局快捷键相同的平台分离模式(接口+平台目录):
**支持的语音指令**
| 指令类型 | 示例指令 | 对应操作 |
| 层 | 职责 | 位置 |
|---|---|---|
| 创建任务 | "创建任务 开会" | 创建标题为"开会"的任务 |
| 创建任务(带优先级) | "创建高优先级任务 提交报告" | 创建高优先级任务 |
| 完成任务 | "完成任务 开会" | 标记任务"开会"为已完成 |
| 删除任务 | "删除任务 开会" | 删除任务"开会" |
| 更新任务 | "更新任务 开会 改为 团队会议" | 更新任务标题 |
| 查询任务 | "查询未完成任务" | 获取未完成任务列表 |
| 添加子任务 | "给任务开会添加子任务 准备PPT" | 为任务添加子任务 |
| `IVoiceInputService` / `IVoiceOutputService` | 接口定义 | `src/Hua.Todo.Core/Services/` |
| MAUI 平台实现 | 各平台原生 STT/TTS | `src/Hua.Todo.Maui/Platforms/{Windows,Android,macOS,iOS}/VoiceInputService.cs` |
| Avalonia 平台实现 | Linux 桌面 STT/TTS | `src/Hua.Todo.Avalonia/Services/Platforms/VoiceInputService.cs` |
| Web 降级方案 | 浏览器 Web Speech API | 前端 `voiceInput.ts` |
### 3.3 意图解析双策略(A+C 方案)
采用 **LLM 主解析 + 规则降级** 的混合方案:
#### 策略 A:在线 — LLM 意图解析(LlmIntentParser
在线模式下,将用户原始文字发送给 LLM,通过 system prompt 约束输出为结构化 JSON
**接口设计**
```
工具名:executeVoiceCommand
参数
- command: string - 语音指令文本
返回:
- success: boolean - 是否执行成功
- message: string - 执行结果描述
- data: object - 返回数据(如任务列表)
System Prompt(精简版):
你是 Hua.Todo 的语音指令解析器。根据用户输入,输出以下 JSON 格式
{
"intent": "CREATE|UPDATE|DELETE|COMPLETE|UNCOMPLETE|QUERY|ADD_SUBTASK|AI_BREAKDOWN|UNKNOWN",
"params": { ... },
"confidence": 0.0-1.0
}
意图说明:
- CREATE: 创建任务,params: { title, priority? }
- UPDATE: 修改任务,params: { targetTitle, newTitle }
- DELETE: 删除任务,params: { targetTitle }
- COMPLETE: 完成任务,params: { targetTitle }
- UNCOMPLETE: 取消完成,params: { targetTitle }
- QUERY: 查询任务,params: { filter? }
- ADD_SUBTASK: 添加子任务,params: { parentTitle, subTitle }
- AI_BREAKDOWN: AI辅助拆分,params: { targetTitle }
- UNKNOWN: 无法识别
只输出 JSON,不要解释。
```
### 3.3 语音状态管理
**接口设计**
**示例调用**
```
工具名:getVoiceStatus
参数:无
返回:
- isListening: boolean - 是否正在监听
- isSpeaking: boolean - 是否正在播报
输入:"帮我把那个开会的任务删了吧"
LLM 输出:
{
"intent": "DELETE",
"params": { "targetTitle": "开会" },
"confidence": 0.95
}
```
```
工具名:speakText
参数
- text: string - 要播报的文本
返回:
- success: boolean - 是否成功
输入:"这个项目太大了,帮我拆一下"
LLM 输出
{
"intent": "AI_BREAKDOWN",
"params": { "targetTitle": "这个项目" },
"confidence": 0.88
}
```
**优势**
- 无需维护同义词表,LLM 天然理解各种自然语言表述
- 扩展新意图只需修改 prompt,不改代码
- 歧义场景 LLM 也能判断(如 confidence < 阈值时触发确认)
#### 策略 C:离线 — 规则匹配降级(RuleIntentParser
离线/嵌入式模式下,降级到关键词规则匹配,只覆盖高频指令:
| 意图 | 匹配规则 | 示例 |
|---|---|---|
| `CREATE` | 正则:`/^(创建|新增|添加|加)\s*(任务|待办)?\s*(.+)/` | "创建任务 开会" |
| `DELETE` | 正则:`/^(删除|删掉|移除)\s*(任务|待办)?\s*(.+)/` | "删除任务 开会" |
| `COMPLETE` | 正则:`/^(完成|做完)\s*(任务|待办)?\s*(.+)/` | "完成任务 开会" |
| `UNCOMPLETE` | 正则:`/^(取消完成|重新打开|恢复)\s*(.+)/` | "取消完成 开会" |
| `UPDATE` | 正则:`/^(修改|更新|编辑)\s*(.+?)\s*(改为|改成|改成)\s*(.+)/` | "修改任务 开会 改为 团队会议" |
| `QUERY` | 正则:`/^(查询|查看|列出|显示)\s*(.+)/` | "查询未完成任务" |
| `ADD_SUBTASK` | 正则:`/^(给|为)\s*(.+?)\s*(添加|加)\s*(子任务|子项)?\s*(.+)/` | "给任务开会添加子任务 准备PPT" |
| `AI_BREAKDOWN` | 正则:`/^(帮我拆分|AI拆分|智能拆分)\s*(.+)/` | "帮我拆分 开会" |
**降级策略**
- 离线模式下 `AI_BREAKDOWN` 意图不可用(需要 LLM),TTS 播报"离线模式不支持 AI 拆分"
- 规则匹配失败时返回 `UNKNOWN`
#### 策略切换逻辑
```csharp
/// <summary>语音意图解析器接口</summary>
public interface IVoiceIntentParser
{
/// <summary>解析语音指令文本,返回意图与参数</summary>
Task<VoiceIntentResult> ParseAsync(string text, CancellationToken ct = default);
}
/// <summary>双策略解析器:在线走 LLM,离线走规则</summary>
public class HybridVoiceIntentParser : IVoiceIntentParser
{
private readonly LlmIntentParser _llmParser;
private readonly RuleIntentParser _ruleParser;
private readonly IConnectivityService _connectivity;
public async Task<VoiceIntentResult> ParseAsync(string text, CancellationToken ct)
{
if (_connectivity.IsOnline)
{
try
{
var result = await _llmParser.ParseAsync(text, ct);
if (result.Intent != VoiceIntent.UNKNOWN)
return result;
}
catch (Exception)
{
// LLM 调用失败,降级到规则
}
}
return _ruleParser.Parse(text);
}
}
```
### 3.4 语音指令处理完整流程
```
原始文字 → HybridVoiceIntentParser
├─ 在线:LlmIntentParserLLM 结构化输出)
└─ 离线/降级:RuleIntentParser(关键词正则匹配)
意图 + 参数 + confidence
├─ confidence >= 阈值 且 目标唯一 → 执行 → TTS 播报
├─ 目标匹配多个 → 返回候选列表 → 用户确认 → 执行
├─ confidence < 阈值 → TTS "您是要...吗?" → 用户确认
└─ UNKNOWN → TTS "没听懂,请再说一次"
```
---
## 四、验收标准
## 四、需求规格
### 4.1 语音输入(STT
各平台通过原生 API 将语音转为文字,通过 `IVoiceInputService` 接口统一暴露:
```csharp
/// <summary>语音输入服务接口,各平台实现原生 STT</summary>
public interface IVoiceInputService
{
/// <summary>是否正在监听</summary>
bool IsListening { get; }
/// <summary>开始语音识别,识别结果通过回调返回</summary>
Task StartListeningAsync(Action<string> onResult, Action<string>? onError = null);
/// <summary>停止语音识别</summary>
Task StopListeningAsync();
}
```
### 4.2 语音播报(TTS
```csharp
/// <summary>语音输出服务接口,各平台实现原生 TTS</summary>
public interface IVoiceOutputService
{
/// <summary>是否正在播报</summary>
bool IsSpeaking { get; }
/// <summary>播报文本</summary>
Task SpeakAsync(string text);
/// <summary>停止播报</summary>
Task StopSpeakingAsync();
}
```
### 4.3 语音指令解析
#### 意图定义
| 意图 (Intent) | 参数 | 说明 |
|---|---|---|
| `CREATE` | `title`(必填)、`priority`(可选:High/Medium/Low | 创建任务 |
| `UPDATE` | `targetTitle`(必填)、`newTitle`(必填) | 修改任务标题 |
| `DELETE` | `targetTitle`(必填) | 删除任务 |
| `COMPLETE` | `targetTitle`(必填) | 标记完成 |
| `UNCOMPLETE` | `targetTitle`(必填) | 取消完成 |
| `QUERY` | `filter`(可选:如"未完成"、"高优先级" | 查询任务 |
| `ADD_SUBTASK` | `parentTitle`(必填)、`subTitle`(必填) | 添加子任务 |
| `AI_BREAKDOWN` | `targetTitle`(必填) | AI 辅助拆分(仅在线) |
| `UNKNOWN` | 无 | 无法识别 |
#### 歧义处理规则
当指令中 `targetTitle` 匹配到多个 Todo 待办项时:
1. **不直接执行**,返回歧义结果(包含匹配的候选列表)
2. 前端展示候选项,用户点选或语音确认后再执行
3. 匹配规则:标题完全匹配优先,包含匹配次之
#### 置信度阈值
- LLM 返回 `confidence >= 0.8`:直接执行
- `0.5 <= confidence < 0.8`:确认后执行(TTS "您是要...吗?"
- `confidence < 0.5`:按 UNKNOWN 处理
### 4.4 语音指令 API
```
POST /api/voice/command
请求体:
{
"text": "帮我把那个开会的删了吧" // STT 识别后的原始文字
}
响应体:
{
"intent": "DELETE", // 解析出的意图
"params": { // 提取的参数
"targetTitle": "开会"
},
"confidence": 0.95, // 置信度
"result": { // 执行结果
"success": true,
"message": "已删除任务:开会",
"data": null
}
}
```
歧义响应:
```json
{
"intent": "COMPLETE",
"params": { "targetTitle": "开会" },
"confidence": 0.92,
"result": {
"success": false,
"message": "找到多个匹配的任务,请确认",
"ambiguity": true,
"candidates": [
{ "id": 1, "title": "开会" },
{ "id": 5, "title": "开会讨论方案" }
]
}
}
```
用户确认后二次请求:
```
POST /api/voice/command/confirm
请求体:
{
"intent": "COMPLETE",
"targetId": 5 // 用户选择的候选 ID
}
```
### 4.5 AI 辅助任务拆分
```
POST /api/voice/ai-breakdown
请求体:
{
"taskId": 5 // 要拆分的目标任务 ID
}
响应体:
{
"suggestions": [ // LLM 生成的子任务建议
{ "title": "准备会议议程", "priority": "Medium" },
{ "title": "发送会议邀请", "priority": "High" },
{ "title": "预定会议室", "priority": "Low" }
]
}
```
用户确认后批量创建:
```
POST /api/voice/ai-breakdown/confirm
请求体:
{
"parentTaskId": 5,
"subTasks": [ // 用户勾选后的子任务列表
{ "title": "准备会议议程", "priority": "Medium" },
{ "title": "发送会议邀请", "priority": "High" }
]
}
```
**关键约束**
- LLM 调用在 Host 端执行,API Key 不暴露到客户端
- 意图解析与 AI 拆分共用同一 LLM 基础设施
- 建议≠直接执行,必须用户确认后才创建子任务
- 离线模式下 AI 拆分不可用,TTS 播报"离线模式不支持 AI 拆分"
- MCP 服务就绪后,`aiBreakdown` 可同步暴露为 MCP 工具
---
## 五、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| 创建任务指令 | 说出"创建任务 测试" | 成功创建标题为"测试"的任务 |
| 创建带优先级任务 | 说出"创建高优先级任务 紧急" | 成功创建高优先级任务 |
| 完成任务指令 | 说出"完成任务 测试" | 任务状态变为已完成 |
| 删除任务指令 | 说出"删除任务 测试" | 任务被成功删除 |
| 查询任务指令 | 说出"查询未完成任务" | 返回未完成任务列表 |
| 语音播报 | 调用 speakText | 成功播放指定文本 |
| 语音通话 | 调用 startVoiceCall | 通话连接成功 |
| STT 语音输入 | 说话后检查回调文字 | 文字识别正确 |
| TTS 语音播报 | 执行指令后检查播报 | 播报内容与执行结果一致 |
| 创建任务(在线) | "帮我把开会的任务加上" | LLM 解析为 CREATE,成功创建 |
| 创建任务(离线降级) | "创建任务 测试" | 规则匹配为 CREATE,成功创建 |
| 创建带优先级任务 | "建一个高优先级任务 紧急" | 成功创建高优先级任务 |
| 完成任务指令 | "把那个开会的事标完成" | LLM 解析为 COMPLETE,执行成功 |
| 取消完成指令 | "取消完成 测试" | 任务状态恢复为未完成 |
| 删除任务指令 | "帮我把测试删了" | LLM 解析为 DELETE,删除成功 |
| 更新任务指令 | "把测试改成验收" | 任务标题更新 |
| 查询任务指令 | "有哪些没做完的" | LLM 解析为 QUERY,返回列表 |
| 添加子任务指令 | "给开会加个子任务 准备PPT" | 子任务创建成功 |
| 歧义处理 | 多个任务含"开会"时说"完成开会" | 返回候选列表,不直接执行 |
| AI 拆分建议 | "帮我拆分 开会" | 返回子任务建议列表 |
| AI 拆分确认 | 用户勾选后确认 | 仅创建勾选的子任务 |
| 离线 AI 拆分 | 离线时说"帮我拆分" | 播报"离线模式不支持 AI 拆分" |
| 无法识别指令 | 说出无关内容 | 播报"没听懂,请再说一次" |
| LLM 降级 | 断网时使用语音 | 自动降级到规则匹配,基本 CRUD 可用 |
---
## 、Touch List
## 、Touch List
| 文件路径 | 修改类型 | 说明 |
|---|---|---|
| `src/Hua.Todo.Application/Voice/` | 新增 | 语音服务目录 |
| `src/Hua.Todo.Application/Voice/VoiceService.cs` | 新增 | 语音服务实现 |
| `src/Hua.Todo.Application/Voice/VoiceCommandParser.cs` | 新增 | 语音指令解析器 |
| `src/Hua.Todo.Application/Voice/Models/` | 新增 | 语音相关 DTO |
| `src/Hua.Todo.Application/Voice/VoiceServiceCollectionExtensions.cs` | 新增 | 服务注册扩展 |
| `src/Hua.Todo.Core/Services/IVoiceInputService.cs` | 新增 | STT 接口定义 |
| `src/Hua.Todo.Core/Services/IVoiceOutputService.cs` | 新增 | TTS 接口定义 |
| `src/Hua.Todo.Core/Services/IVoiceIntentParser.cs` | 新增 | 意图解析器接口 |
| `src/Hua.Todo.Application/Voice/LlmIntentParser.cs` | 新增 | LLM 意图解析器(在线策略) |
| `src/Hua.Todo.Application/Voice/RuleIntentParser.cs` | 新增 | 规则匹配解析器(离线降级策略) |
| `src/Hua.Todo.Application/Voice/HybridVoiceIntentParser.cs` | 新增 | 双策略解析器(在线走 LLM,离线走规则) |
| `src/Hua.Todo.Application/Voice/VoiceCommandIntent.cs` | 新增 | 意图枚举定义 |
| `src/Hua.Todo.Application/Voice/VoiceCommandExecutor.cs` | 新增 | 意图执行器(分发到 TaskService |
| `src/Hua.Todo.Application/Voice/VoiceController.cs` | 新增 | 语音指令 API 端点(`/api/voice/command``/api/voice/ai-breakdown` |
| `src/Hua.Todo.Application/Voice/AiBreakdownService.cs` | 新增 | AI 辅助拆分服务(调用 LLM 生成建议) |
| `src/Hua.Todo.Application/Voice/LlmClientService.cs` | 新增 | LLM 调用封装(意图解析 + AI 拆分共用) |
| `src/Hua.Todo.Application/Voice/Models/` | 新增 | 语音相关 DTOVoiceIntentResult、VoiceCommandRequest 等) |
| `src/Hua.Todo.Application/Voice/VoiceServiceCollectionExtensions.cs` | 新增 | 语音服务 DI 注册 |
| `src/Hua.Todo.Maui/Platforms/Windows/VoiceInputService.cs` | 新增 | Windows STT 实现 |
| `src/Hua.Todo.Maui/Platforms/Windows/VoiceOutputService.cs` | 新增 | Windows TTS 实现 |
| `src/Hua.Todo.Maui/Platforms/Android/VoiceInputService.cs` | 新增 | Android STT 实现 |
| `src/Hua.Todo.Maui/Platforms/Android/VoiceOutputService.cs` | 新增 | Android TTS 实现 |
| `src/Hua.Todo.Maui/Platforms/MacCatalyst/VoiceInputService.cs` | 新增 | macOS STT 实现 |
| `src/Hua.Todo.Maui/Platforms/MacCatalyst/VoiceOutputService.cs` | 新增 | macOS TTS 实现 |
| `src/Hua.Todo.Maui/Platforms/iOS/VoiceInputService.cs` | 新增 | iOS STT 实现 |
| `src/Hua.Todo.Maui/Platforms/iOS/VoiceOutputService.cs` | 新增 | iOS TTS 实现 |
| `src/Hua.Todo.Avalonia/Services/Platforms/VoiceInputService.cs` | 新增 | Linux STT 实现 |
| `src/Hua.Todo.Avalonia/Services/Platforms/VoiceOutputService.cs` | 新增 | Linux TTS 实现 |
| `src/Hua.Todo.Web/src/api/voice.ts` | 新增 | 前端语音 API 模块 |
| `src/Hua.Todo.Web/src/composables/useVoiceInput.ts` | 新增 | 前端语音输入组合式函数(含 Web Speech API 降级) |
---
**工单编号**02
**标题**:语音通话与语音控制功能
**标题**:语音控制与 AI 辅助
**版本**v1.3.0
**修订日期**2026-06-16
---
## 七、与其他工单的语音入口衔接
### 7.1 背景
Hua.Todo 目前有两个功能入口:**UI 入口**(WebView / 前端界面)和**语音控制入口**(本工单产出)。后续每项新增功能在需求阶段都必须确认这两个入口的覆盖情况。
### 7.2 工单 03(会议任务拆分)的语音入口
工单 03 的会议功能需要通过语音控制入口提供以下指令:
| 语音指令 | 意图 | 参数 | 说明 |
|---|---|---|---|
| "新建会议 项目评审" | `CREATE_MEETING` | `title`(必填)、`priority`(可选) | 创建会议类型任务(TaskType.Meeting |
| "记录会议内容" | `RECORD_MEETING` | `targetTitle`(必填) | 开始录制当前会议 |
| "停止录制" | `STOP_RECORDING` | 无 | 停止录制,触发转写 |
| "帮我拆分这个会议" | `AI_BREAKDOWN` | `targetTitle`(必填,目标为会议类型) | 对会议内容进行 AI 拆分(复用 AI_BREAKDOWN 意图,参数携带 TaskType=Meeting |
| "查看会议待办项" | `QUERY` | `filter="会议"` | 查询所有会议类型任务 |
**关键点**
- `AI_BREAKDOWN` 意图需扩展以区分普通任务拆分和会议拆分(通过 `TaskType` 字段),会议拆分的 prompt 侧重"提取行动项"
- `CREATE_MEETING``CREATE` 意图的子类型,创建时字段 `taskType = Meeting`,需要 LLM intent parser 的 prompt 增加说明
- 录音控制(`RECORD_MEETING` / `STOP_RECORDING`)是新意图,对应前端 MediaRecorder 的启动/停止
### 7.3 语音入口覆盖检查表
后续每个新增功能/工单,智能体必须在需求讨论阶段输出以下检查表:
| 检查项 | 状态 | 备注 |
|---|---|---|
| UI 入口是否已规划 | ✅ / ❌ | 描述 UI 入口 |
| 语音控制入口是否已规划 | ✅ / ❌ | 描述对应的语音指令与意图 |
| 当前不可覆盖的入口 | 说明原因 | 如:语音控制暂不支持 XX 操作(选项过多/需可视化交互) |
此规则已固化为项目规范,详见 [.trae/rules/项目/05-多入口功能同步规范.md](../../../.trae/rules/项目/05-多入口功能同步规范.md)。
@@ -0,0 +1,241 @@
# 研发工单 v1.3.0 - 03-01 会议数据模型与 API
> 父工单:[03-会议任务拆分](./03-会议任务拆分.md)
---
## 一、目标
为"会议任务拆分"功能建立数据模型:扩展 `TaskEntity` 新增 `TaskType` 枚举和 `MeetingNotes`/`AudioDuration` 字段,定义完整的会议相关 API 端点与 DTO,完成数据库迁移。
## 二、范围
**包含**
- 新增 `TaskType` 枚举(Normal / Meeting
- `TaskEntity` 新增字段:`TaskType``MeetingNotes``AudioDuration`
- EF Core 数据库迁移
- DTO 扩展:`CreateTaskDto``UpdateTaskDto``TaskDto` 新增对应字段
- 会议 API 端点骨架:`MeetingController` + `MeetingService`
- API 路径:`/api/meeting/{taskId}/transcribe``/api/meeting/{taskId}/notes`
- 会议相关 DTO 定义:`TranscribeRequest``TranscribeResponse``MeetingNotesRequest`
**不包含**
- AI 拆分逻辑(由 03-03 完成)
- STT 转写实现(由 03-02 完成)
- 前端组件(由 03-04 完成)
## 三、前置条件
| 条件 | 说明 |
|---|---|
| Hua.Todo v1.2.0 | 基础 Task CRUD 能力、父子任务、DynamicApi 中间件 |
| ABP 基类迁移(可选) | 若工单 09v1.2.0)已完成,TaskEntity 已转为 ABP 基类,字段新增方式不同 |
## 四、详细规格
### 4.1 TaskType 枚举
```csharp
/// <summary>待办项类型,区分普通待办项与会议</summary>
public enum TaskType
{
/// <summary>普通待办项(默认)</summary>
Normal = 0,
/// <summary>会议:包含录音/纪要,支持 AI 任务拆分</summary>
Meeting = 1
}
```
文件位置:`src/Hua.Todo.Core/Entities/TaskType.cs`
### 4.2 TaskEntity 新增字段
```csharp
/// <summary>待办项类型</summary>
public TaskType TaskType { get; set; } = TaskType.Normal;
/// <summary>会议纪要/转写文字(仅 Meeting 类型有值)</summary>
[MaxLength(20000)]
public string? MeetingNotes { get; set; }
/// <summary>录音时长(秒),仅 Meeting 类型有值</summary>
public double? AudioDuration { get; set; }
```
### 4.3 EF Core 配置(TodoDbContext.cs
```csharp
builder.Entity<TaskEntity>(b =>
{
// ... 现有配置 ...
b.Property(x => x.TaskType)
.HasDefaultValue(TaskType.Normal)
.HasConversion<int>(); // 枚举存为整数
b.Property(x => x.MeetingNotes)
.HasMaxLength(20000); // 最多约 20000 字符
b.Property(x => x.AudioDuration)
.IsRequired(false);
});
```
### 4.4 数据库迁移
```
dotnet ef migrations add AddMeetingFieldsToTasks
```
迁移应在 `Hua.Todo.Application/Migrations/` 目录生成。
### 4.5 DTO 扩展
**CreateTaskDto** 新增:
```csharp
/// <summary>待办项类型(0=Normal, 1=Meeting),默认 Normal</summary>
public TaskType TaskType { get; set; } = TaskType.Normal;
```
**TaskDto** 新增:
```csharp
/// <summary>待办项类型</summary>
public TaskType TaskType { get; set; }
/// <summary>会议纪要/转写文字</summary>
public string? MeetingNotes { get; set; }
/// <summary>录音时长(秒)</summary>
public double? AudioDuration { get; set; }
```
### 4.6 会议 DTO
```csharp
/// <summary>转写请求</summary>
public class TranscribeRequest
{
public IFormFile Audio { get; set; } = null!;
public string? Format { get; set; }
}
/// <summary>转写响应</summary>
public class TranscribeResponse
{
public int TaskId { get; set; }
public string Transcript { get; set; } = string.Empty;
public double AudioDuration { get; set; }
}
/// <summary>保存会议纪要请求</summary>
public class MeetingNotesRequest
{
public string Notes { get; set; } = string.Empty;
}
/// <summary>会议纪要响应</summary>
public class MeetingNotesResponse
{
public int TaskId { get; set; }
public string MeetingNotes { get; set; } = string.Empty;
}
```
### 4.7 API 端点
#### POST /api/meeting/{taskId}/transcribe
- 接收:multipart/form-data(音频文件)
- 返回:`TranscribeResponse`
- 业务:音频上传后异步转写,结果存回 `TaskEntity.MeetingNotes`
- 当前骨架:返回占位文字,具体转写逻辑由 03-02 实现
#### POST /api/meeting/{taskId}/notes
- 接收:`MeetingNotesRequest`
- 返回:`MeetingNotesResponse`
- 业务:保存/更新会议纪要
### 4.8 MeetingService 骨架
```csharp
/// <summary>会议业务服务</summary>
public class MeetingService
{
private readonly ITaskRepository _taskRepo;
public MeetingService(ITaskRepository taskRepo)
{
_taskRepo = taskRepo;
}
/// <summary>验证 taskId 对应的待办项存在且为 Meeting 类型</summary>
public async Task<TaskEntity> GetMeetingTaskOrThrow(int taskId)
{
var task = await _taskRepo.GetAsync(taskId);
if (task == null)
throw new NotFoundException($"任务 {taskId} 不存在");
if (task.TaskType != TaskType.Meeting)
throw new BusinessException($"任务 {taskId} 不是会议类型");
return task;
}
/// <summary>保存会议纪要</summary>
public async Task<TaskEntity> SaveNotes(int taskId, string notes)
{
var task = await GetMeetingTaskOrThrow(taskId);
task.MeetingNotes = notes;
task.UpdatedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
return task;
}
/// <summary>保存转写结果与音频时长</summary>
public async Task<TaskEntity> SaveTranscript(int taskId, string transcript, double audioDuration)
{
var task = await GetMeetingTaskOrThrow(taskId);
task.MeetingNotes = transcript;
task.AudioDuration = audioDuration;
task.UpdatedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
return task;
}
}
```
## 五、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| TaskType 枚举可用 | 编译通过 | `TaskType.Normal` / `TaskType.Meeting` 可正常赋值 |
| DB 迁移可执行 | `dotnet ef database update` | `Tasks` 表新增 `TaskType`/`MeetingNotes`/`AudioDuration` 列 |
| 创建 Meeting 类型任务 | `POST /api/task``taskType:1` | 返回的 TaskDto 中 `taskType=1` |
| 保存会议纪要 | `POST /api/meeting/{id}/notes` | `meetingNotes` 字段更新成功 |
| 非 Meeting 类型调用会议 API | 普通任务调 `/api/meeting/{id}/notes` | 返回业务异常 |
| 现有代码兼容 | 运行所有已有测试 | 不破坏现有业务 |
## 六、风险
| 风险 | 影响 | 应对 |
|---|---|---|
| TaskEntity 已重构为 ABP 基类 | 字段新增方式不同 | 通过 ABP 的 `ExtraProperties` 或标准字段新增,迁移方式略有调整 |
| 字段长度限制 | 超长会议纪要截断 | `MeetingNotes``MaxLength(20000)`,前端也做长度限制 |
## 七、Touch List
| 文件路径 | 修改类型 | 是否共享 |
|---|---|---|
| `src/Hua.Todo.Core/Entities/TaskType.cs` | 新增 | 否 |
| `src/Hua.Todo.Core/Entities/TaskEntity.cs` | 修改 | 是 |
| `src/Hua.Todo.Application/Data/TodoDbContext.cs` | 修改 | 是 |
| `src/Hua.Todo.Application/Models/TaskModels.cs` | 修改 | 是 |
| `src/Hua.Todo.Application/Meeting/MeetingController.cs` | 新增 | 否 |
| `src/Hua.Todo.Application/Meeting/MeetingService.cs` | 新增 | 否 |
| `src/Hua.Todo.Application/Meeting/Models/MeetingDtos.cs` | 新增 | 否 |
| `src/Hua.Todo.Web/src/types/task.ts` | 修改 | 是 |
| `Migrations/AddMeetingFieldsToTasks.cs` | 新增 | 是 |
---
**工单编号**03-01
**标题**:会议数据模型与 API
**版本**v1.3.0
**创建日期**2026-06-16
@@ -0,0 +1,199 @@
# 研发工单 v1.3.0 - 03-02 音频录制与转写
> 父工单:[03-会议任务拆分](./03-会议任务拆分.md)
>
> 依赖:03-01API 契约)
---
## 一、目标
实现前端音频录制(MediaRecorder API)和后端音频转写(STT)能力,打通"录音 → 上传 → 转写文字 → 保存为会议纪要"的完整链路。
## 二、范围
**包含**
- 前端录音控件(`AudioRecorder.vue`
- 前端录音组合式函数(`useAudioRecorder.ts`
- 录音状态管理(录制中/暂停/停止/时长显示)
- 音频上传 API 交互(POST multipart/form-data
- 后端音频文件临时存储
- 后端 STT 转写服务(`SttService.cs`
- 转写结果回写 `MeetingNotes`
**不包含**
- 实时转写(边录边转)
- 音频持久存储(转写完成后删除音频文件)
- 音频云同步
- 语音指令输入(属于工单 02
## 三、前置条件
| 条件 | 说明 |
|---|---|
| 03-01 完成 | `MeetingController``MeetingService` 基础骨架就绪 |
## 四、详细规格
### 4.1 前端录音控件
**AudioRecorder.vue**
```
┌────────────────────────────────┐
│ 🎤 会议录音 │
│ │
│ ● 录制中... 00:15:23 │
│ ┌──────────────────────────┐ │
│ │ ▁▃▂▄▅▂▁▃▄▅▃▂▁▂▄▅▃▁ │ │ ← 简易波形
│ └──────────────────────────┘ │
│ │
│ [⏹ 停止录音] │
│ │
│ 录音时长限制:最长 2 小时 │
└────────────────────────────────┘
```
状态:
- **就绪**:显示录音按钮
- **录制中**:显示停止按钮 + 时长计时 + 简易波形条
- **已停止**:显示"提交转写" / "重新录制"
### 4.2 useAudioRecorder 组合式函数
```typescript
/// <summary>音频录制器组合式函数,封装 MediaRecorder API</summary>
export function useAudioRecorder() {
// 状态
const isRecording = ref(false)
const isPaused = ref(false)
const duration = ref(0) // 秒
const audioBlob = ref<Blob | null>(null)
const audioUrl = ref<string | null>(null) // 用于预览播放
// 方法
async function startRecording(): Promise<void> // 请求麦克风权限,开始录制
function stopRecording(): void // 停止录制,生成 Blob
function resetRecording(): void // 重置状态
function getAudioBlob(): Blob | null // 获取录制结果
// 内部
let mediaRecorder: MediaRecorder | null = null
let timerInterval: number | null = null
// 音频格式:webmChrome/Firefox)、mp4Safari
// 时长限制:最长 2 小时(7200 秒)
return { isRecording, isPaused, duration, audioBlob, audioUrl,
startRecording, stopRecording, resetRecording, getAudioBlob }
}
```
### 4.3 前端 API 模块
```typescript
// src/Hua.Todo.Web/src/api/meeting.ts
/// <summary>上传音频文件并请求转写</summary>
async function transcribeAudio(taskId: number, audioBlob: Blob): Promise<TranscribeResponse>
{
const formData = new FormData();
formData.append('audio', audioBlob, 'meeting.webm');
const apiBaseUrl = window.__API_BASE_URL__ || 'http://localhost:5173/api';
const resp = await fetch(`${apiBaseUrl}/meeting/${taskId}/transcribe`, {
method: 'POST',
body: formData
});
if (!resp.ok) throw new Error(`转写请求失败: ${resp.status}`);
return resp.json();
}
```
### 4.4 后端 SttService
```csharp
/// <summary>语音转写服务接口</summary>
public interface ISttService
{
/// <summary>将音频文件转写为文字</summary>
/// <returns>转写文字</returns>
Task<string> TranscribeAsync(Stream audioStream, string format, CancellationToken ct = default);
}
```
实现策略(按优先级):
| 平台/环境 | 实现方式 | 说明 |
|---|---|---|
| Windows MAUI | `Windows.Media.SpeechRecognition` 文件识别 | 系统自带,离线可用 |
| macOS/iOS | `SFSpeechRecognizer` 文件识别 | 需在线 |
| Android | `SpeechRecognizer` | 需在线 |
| Linux | WebKit 在线;本地 `whisper.cpp` 降级 | 多策略 |
| 通用服务端 | 扩展 `LlmClientService` 调用 Whisper API | Host 端部署 |
> 初期(v1.3.0)优先实现服务端 Whisper API 调用方式(通过 `LlmClientService` 扩展),后续版本各平台原生逐补。
### 4.5 音频上传处理流程
```
前端 AudioRecorder → stopRecording → Blob (webm/mp4)
POST /api/meeting/{taskId}/transcribe (multipart/form-data)
MeetingController.Transcribe()
↓ 保存音频临时文件到 meetings/ 目录
↓ 调用 ISttService.TranscribeAsync()
↓ 得到文字结果
↓ 删除临时音频文件
↓ 调用 MeetingService.SaveTranscript() 保存到数据库
返回 TranscribeResponse { taskId, transcript, audioDuration }
```
### 4.6 错误处理
| 场景 | 处理 |
|---|---|
| 音频格式不支持 | 返回 400 "不支持的音频格式,支持 webm/wav/mp3" |
| 音频文件太大 | 返回 400 "音频文件过大,请控制录音在 2 小时以内" |
| STT 服务不可用 | 返回 503 "转写服务暂不可用,请稍后重试" |
| taskId 不是会议类型 | 返回 400 "该待办项不是会议类型" |
## 五、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| 录音按钮可用 | 点击录音按钮 | 浏览器弹出麦克风权限请求 |
| 录制过程 | 授权后开始录制 | 显示录音时长,波形条有变化 |
| 停止录音 | 点击停止按钮 | 时长停止,显示"提交转写"按钮 |
| 重新录制 | 点击"重新录制" | 状态重置,可再次录制 |
| 上传转写 | 提交录音文件 | 返回转写文字 |
| 纪要保存 | 转写完成后查看任务 | `meetingNotes` 字段有转写文字 |
| 权限拒绝 | 浏览器拒绝麦克风 | 提示"无法访问麦克风,请使用文字输入" |
| 浏览器不支持 | IE/Safari 旧版等 | 提示"当前浏览器不支持录音,请使用文字输入" |
## 六、风险
| 风险 | 影响 | 应对 |
|---|---|---|
| Safari 不支持 webm | 无法录制 | 使用 mp4 格式(Safari 支持);MIME type 自动适配 |
| STT 准确率不足 | 转写错误多 | 支持用户编辑修正转写结果 |
| 长音频转写耗时长 | 用户等待 | 前端显示转写进度或"转写中"加载状态 |
## 七、Touch List
| 文件路径 | 修改类型 |
|---|---|
| `src/Hua.Todo.Web/src/components/AudioRecorder.vue` | 新增 |
| `src/Hua.Todo.Web/src/composables/useAudioRecorder.ts` | 新增 |
| `src/Hua.Todo.Web/src/api/meeting.ts` | 新增 |
| `src/Hua.Todo.Application/Meeting/SttService.cs` | 新增 |
| `src/Hua.Todo.Application/Meeting/MeetingController.cs` | 修改 |
---
**工单编号**03-02
**标题**:音频录制与转写
**版本**v1.3.0
**创建日期**2026-06-16
@@ -0,0 +1,281 @@
# 研发工单 v1.3.0 - 03-03 AI 任务拆分服务
> 父工单:[03-会议任务拆分](./03-会议任务拆分.md)
>
> 依赖:03-01API 契约)、工单 02`LlmClientService`
---
## 一、目标
实现基于 LLM 的会议内容分析服务:接收会议纪要文字,通过 LLM prompt 工程提取行动项,生成结构化的待办项建议列表(含标题、优先级、理由)。
## 二、范围
**包含**
- `MeetingAiBreakdownService`:会议文本 → LLM → 结构化建议列表
- 会议拆分专用 prompt 设计与迭代
- AI 拆分 API 端点:`POST /api/meeting/{taskId}/breakdown`
- 批量创建确认端点:`POST /api/meeting/{taskId}/breakdown/confirm`
- 在线可用性检测(离线时拒绝 AI 拆分请求)
- 复用工单 02 的 `LlmClientService`
**不包含**
- 单任务拆分(属于工单 02 `AI_BREAKDOWN` 意图)
- LLM 基础设施搭建(由工单 02 提供)
- 前端审阅 UI(由 03-04 完成)
## 三、前置条件
| 条件 | 说明 |
|---|---|
| 03-01 完成 | `MeetingController` 骨架就绪 |
| 工单 02 `LlmClientService` | LLM 调用封装可用 |
## 四、详细规格
### 4.1 MeetingAiBreakdownService
```csharp
/// <summary>会议 AI 拆分服务,将会议文字内容分析为待办项建议</summary>
public class MeetingAiBreakdownService
{
private readonly ILlmClientService _llmClient;
private readonly MeetingService _meetingService;
private readonly ITaskService _taskService;
public MeetingAiBreakdownService(
ILlmClientService llmClient,
MeetingService meetingService,
ITaskService taskService) { ... }
/// <summary>分析会议内容,返回待办项建议列表</summary>
public async Task<List<MeetingTaskSuggestion>> AnalyzeAsync(
int taskId, string? notes = null, CancellationToken ct = default)
{
// 1. 验证 taskId 为 Meeting 类型
var meeting = await _meetingService.GetMeetingTaskOrThrow(taskId);
// 2. 获取会议文字(参数优先,否则取数据库中的 meetingNotes
var meetingText = notes ?? meeting.MeetingNotes;
if (string.IsNullOrWhiteSpace(meetingText))
throw new BusinessException("会议内容为空,请先输入纪要或上传录音");
// 3. 构建 prompt,调用 LLM
var prompt = BuildBreakdownPrompt(meetingText, meeting.Title);
var response = await _llmClient.SendAsync(prompt, ct);
// 4. 解析 LLM 返回的 JSON 为建议列表
return ParseSuggestions(response);
}
/// <summary>确认建议并批量创建子任务</summary>
public async Task<BatchCreateResult> ConfirmAndCreateAsync(
int taskId, List<SubTaskCreateItem> subTasks, CancellationToken ct = default)
{
// 1. 验证 taskId 为 Meeting 类型
await _meetingService.GetMeetingTaskOrThrow(taskId);
// 2. 批量创建子任务
var created = new List<TaskDto>();
foreach (var item in subTasks)
{
var dto = new CreateTaskDto
{
Title = item.Title,
Priority = item.Priority,
ParentTaskId = taskId
};
created.Add(await _taskService.CreateTaskAsync(dto));
}
return new BatchCreateResult { CreatedCount = created.Count, SubTasks = created };
}
}
```
### 4.2 LLM Prompt 设计
```
System Prompt:
你是专业的项目管理和会议纪要分析助手。根据用户提供的会议内容,提取所有需要
后续行动的事项,生成待办项建议列表。
要求:
1. 每条建议包含标题(title)、优先级(priority)、原因(reason
2. 标题应简洁明确(15 字以内),如"整理需求文档"、"安排评审会议"
3. 优先级:High(2)=紧急重要,Medium(1)=一般,Low(0)=可延迟
4. 原因应解释为什么需要这个待办项,引用会议中的具体决策或讨论
5. 只提取明确需要执行的事项,不要生成模糊或无来源的建议
6. 建议数量根据会议内容合理判断,通常 3-10 条
7. 如果会议内容无法提取出明确的行动项,返回空列表
输出格式(只输出 JSON,不要解释):
{
"suggestions": [
{
"title": "待办项标题",
"priority": 1,
"reason": "原因说明"
}
]
}
```
### 4.3 DTO 定义
```csharp
/// <summary>会议任务拆分建议</summary>
public class MeetingTaskSuggestion
{
/// <summary>建议的待办项标题</summary>
public string Title { get; set; } = string.Empty;
/// <summary>建议优先级</summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>拆分原因(LLM 输出,用于 UI 展示)</summary>
public string Reason { get; set; } = string.Empty;
}
/// <summary>拆分请求</summary>
public class BreakdownRequest
{
/// <summary>会议纪要文字(可选,不传则使用已保存的 meetingNotes</summary>
public string? Notes { get; set; }
}
/// <summary>拆分响应</summary>
public class BreakdownResponse
{
public int TaskId { get; set; }
public List<MeetingTaskSuggestion> Suggestions { get; set; } = new();
}
/// <summary>批量创建请求中的单个子任务</summary>
public class SubTaskCreateItem
{
public string Title { get; set; } = string.Empty;
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
}
/// <summary>批量确认请求</summary>
public class ConfirmBreakdownRequest
{
public List<SubTaskCreateItem> SubTasks { get; set; } = new();
}
/// <summary>批量创建结果</summary>
public class BatchCreateResult
{
public int CreatedCount { get; set; }
public List<TaskDto> SubTasks { get; set; } = new();
}
```
### 4.4 API 端点
#### POST /api/meeting/{taskId}/breakdown
- 接收:`BreakdownRequest`
- 返回:`ApiResponse<BreakdownResponse>`
- 处理:
1. 获取会议文字(参数优先于数据库)
2. 调用 `MeetingAiBreakdownService.AnalyzeAsync()`
3. 返回建议列表
- 错误:
- 内容为空 → 400 "会议内容为空"
- 离线 → 503 "AI 拆分仅在线可用"
- taskId 非 Meeting 类型 → 400
#### POST /api/meeting/{taskId}/breakdown/confirm
- 接收:`ConfirmBreakdownRequest`
- 返回:`ApiResponse<BatchCreateResult>`
- 处理:
1. 验证 taskId 为 Meeting 类型
2. 逐条创建子任务(通过 `TaskService.CreateTaskAsync`
3. 返回创建结果
### 4.5 LLM 响应解析
```csharp
/// <summary>解析 LLM 返回的 JSON 为建议列表</summary>
private List<MeetingTaskSuggestion> ParseSuggestions(string llmResponse)
{
// 1. 清理 LLM 响应(移除可能的 markdown 代码块包裹、前后空白)
var json = CleanJsonResponse(llmResponse);
// 2. 反序列化
var result = JsonSerializer.Deserialize<LlBreakdownResponse>(json);
// 3. 验证
if (result?.Suggestions == null || result.Suggestions.Count == 0)
return new List<MeetingTaskSuggestion>();
// 4. 过滤无效条目(标题为空)
return result.Suggestions
.Where(s => !string.IsNullOrWhiteSpace(s.Title))
.Select(s => new MeetingTaskSuggestion
{
Title = s.Title.Trim(),
Priority = s.Priority,
Reason = s.Reason?.Trim() ?? string.Empty
})
.ToList();
}
/// <summary>LLM 原始响应结构</summary>
private class LlBreakdownResponse
{
public List<MeetingTaskSuggestion> Suggestions { get; set; } = new();
}
```
### 4.6 错误处理
| 场景 | HTTP 状态码 | message |
|---|---|---|
| 会议文字内容为空 | 400 | 会议内容为空,请先输入纪要或上传录音 |
| taskId 不存在 | 404 | 待办项不存在 |
| taskId 不是 Meeting 类型 | 400 | 该待办项不是会议类型 |
| LLM API 调用失败 | 502 | AI 拆分服务暂时不可用,请稍后重试 |
| LLM 返回格式异常 | 500 | AI 拆分结果解析失败 |
| 离线 | 503 | AI 拆分仅在线可用 |
| 确认时子任务列表为空 | 400 | 请至少选择一个待办项 |
## 五、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| 会议内容分析 | 提交一段会议纪要文字 | 返回 3-10 条结构化建议 |
| 建议质量 | 检查建议的标题、优先级、原因 | 标题明确、优先级合理、原因引用会议内容 |
| 空内容拒绝 | 不传 notes 且 meetingNotes 为空 | 返回 400 |
| 非 Meeting 类型拒绝 | 传普通待办项 ID | 返回 400 |
| 在线依赖 | 离线时请求拆分 | 返回 503 |
| 批量创建 | 确认勾选的建议 | 子任务批量创建成功 |
| LLM 异常降级 | 模拟 LLM 调用失败 | 返回 502,不影响其他功能 |
| 空结果处理 | 会议内容无可提取的待办项 | 返回空建议列表 + 提示信息 |
## 六、风险
| 风险 | 影响 | 应对 |
|---|---|---|
| LLM 返回格式不符 | 建议解析失败 | 增加 JSON 清理逻辑(移除 markdown 包裹、前后文本) |
| LLM Token 限制 | 长会议纪要超出上下文窗口 | 限制文本长度(如 4000 字);超长时截断并告知用户 |
| Prompt 需要迭代 | 建议质量不达预期 | prompt 作为配置项,支持热更新 |
## 七、Touch List
| 文件路径 | 修改类型 |
|---|---|
| `src/Hua.Todo.Application/Meeting/MeetingAiBreakdownService.cs` | 新增 |
| `src/Hua.Todo.Application/Meeting/MeetingController.cs` | 修改 |
| `src/Hua.Todo.Application/Meeting/Models/MeetingDtos.cs` | 修改 |
---
**工单编号**03-03
**标题**AI 任务拆分服务
**版本**v1.3.0
**创建日期**2026-06-16
@@ -0,0 +1,295 @@
# 研发工单 v1.3.0 - 03-04 任务建议与确认 UI
> 父工单:[03-会议任务拆分](./03-会议任务拆分.md)
>
> 依赖:03-01、03-03(后端 API 就绪)
---
## 一、目标
实现会议任务拆分的完整前端 UI:在 TaskItem 中为 Meeting 类型待办项提供录音/纪要/拆分入口,实现建议审阅与确认对话框,打通从会议内容输入到批量创建子任务的端到端用户体验。
## 二、范围
**包含**
- Meeting 类型待办项的特殊 UI(图标、录音/纪要/拆分入口)
- 录音控件的嵌入(调用 03-02 的 `AudioRecorder.vue` + `useAudioRecorder`
- 纪要输入区域(textarea 保存会议文字内容)
- AI 拆分请求与加载状态
- 建议审阅对话框(`MeetingBreakdownDialog.vue`
- 建议勾选、编辑、调整优先级、手动补充
- 确认后批量创建子任务
- 前端类型扩展(`taskType``meetingNotes``audioDuration`
**不包含**
- 录音控件本身(由 03-02 提供)
- 后端逻辑(由 03-01、03-03 提供)
## 三、前置条件
| 条件 | 说明 |
|---|---|
| 03-01 完成 | 后端 API 就绪(meeting 端点 + TaskType |
| 03-02 完成 | AudioRecorder 组件和 useAudioRecorder 可用 |
| 03-03 完成 | breakdown/breakdown/confirm 端点可用 |
## 四、详细规格
### 4.1 前端类型扩展
```typescript
// src/Hua.Todo.Web/src/types/task.ts
/// <summary>待办项类型</summary>
type TaskType = 0 | 1; // 0=Normal, 1=Meeting
interface Task {
// ... 已有字段 ...
/** 待办项类型 */
taskType?: TaskType;
/** 会议纪要/转写文字 */
meetingNotes?: string;
/** 录音时长(秒) */
audioDuration?: number;
}
interface CreateTaskDto {
title: string;
priority: TaskPriority;
parentTaskId?: number;
/** 待办项类型,默认 0 */
taskType?: TaskType;
}
```
### 4.2 会议相关类型
```typescript
// 新增或扩展 meeting.ts 中的类型
/// <summary>会议任务拆分建议</summary>
interface MeetingTaskSuggestion {
title: string;
priority: TaskPriority;
reason: string;
}
/// <summary>拆分响应</summary>
interface BreakdownResponse {
taskId: number;
suggestions: MeetingTaskSuggestion[];
}
/// <summary>待确认的子任务项</summary>
interface PendingSubTask {
title: string;
priority: TaskPriority;
/** 是否被用户勾选 */
checked: boolean;
/** LLM 拆分的原始建议(含原因) */
suggestion?: MeetingTaskSuggestion;
/** 是否为用户手动添加的条目 */
isManual?: boolean;
}
```
### 4.3 TaskItem.vue 扩展
为 Meeting 类型的待办项显示额外操作入口:
```
┌──────────────────────────────────────────────┐
│ 📋 ☐ 周一产品评审会 🔴 H ··· │
│ 🎙 会议 · 📝 有纪要 · 0个子任务 │
│ [🎤] [📝] [🤖] │
│ ┌─ ✅ 整理需求文档 🔴 │ ← 已有子任务(若有)
│ └─ ☐ 安排评审会议 🟡 │
└──────────────────────────────────────────────┘
[🎤] = 录音按钮
[📝] = 打开纪要编辑区
[🤖] = AI 拆分(需有会议内容)
```
条件渲染逻辑:
- `task.taskType === 1`Meeting)时,显示会议图标 `🎙` 标签
- 显示三个操作按钮在任务行右侧
- 拆分子任务入口:点击 `[🤖]` → 触发 `handleAiBreakdown()`
### 4.4 会议纪要编辑区(内联)
点击 `[📝]` 后展开内联编辑区:
```
┌──────────────────────────────────────────────┐
│ 📋 ☐ 周一产品评审会 │
│ │
│ 会议纪要: │
│ ┌──────────────────────────────────────┐ │
│ │ 今天的产品评审会主要讨论了三个议题: │ │
│ │ 第一,确定 Q3 产品路线图... │ │
│ │ 第二,... │ │
│ │ │ │
│ └──────────────────────────────────────┘ │
│ 录音时长:30分15秒 │
│ │
│ [🔊 开始录音] [🤖 AI拆分] [💾 保存纪要] │
└──────────────────────────────────────────────┘
```
### 4.5 AI 拆分流程(前端)
```typescript
// 在 TaskItem.vue 或拆分逻辑中
async function handleAiBreakdown(task: Task) {
// 1. 检查是否有会议内容
if (!task.meetingNotes) {
showToast('请先输入纪要或上传录音', 'warning');
return;
}
// 2. 调用 AI 拆分
isLoading.value = true;
try {
const resp = await meetingApi.requestBreakdown(task.id);
if (resp.data.suggestions.length === 0) {
showToast('未从会议内容中提取到待办项,请确认纪要内容', 'info');
return;
}
// 3. 打开审阅对话框
openBreakdownDialog(task, resp.data.suggestions);
} catch (err) {
showToast('AI拆分失败,请稍后重试', 'error');
} finally {
isLoading.value = false;
}
}
```
### 4.6 MeetingBreakdownDialog.vue
审阅确认对话框:
```
┌──────────────────────────────────────────────┐
│ 会议任务拆分 — 周一产品评审会 [✕] │
│ │
│ AI 根据会议内容生成了以下待办项建议: │
│ 请勾选需要创建的条目,可编辑标题和优先级。 │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ ☑ 整理产品需求文档 [🔴 高 ▼] │ │
│ │ 📝 会议中提到需要在本周五前完成 │ │
│ │ │ │
│ │ ☑ 安排技术方案评审 [🟡 中 ▼] │ │
│ │ 📝 张三负责在下周三前输出技术方案 │ │
│ │ │ │
│ │ ☑ 跟进客户反馈 [🟢 低 ▼] │ │
│ │ 📝 李四反馈了三个客户问题 │ │
│ │ │ │
│ │ ☐ 项目总结报告 [🟡 中 ▼] │ │
│ │ 📝 月末需要汇总各模块进度 │ │
│ └──────────────────────────────────────┘ │
│ │
│ [+ 手动添加待办项] │
│ │
│ 已选 3 项 │
│ [取消] [✅ 确认创建 (3)] │
└──────────────────────────────────────────────┘
```
交互:
- 每条建议默认勾选
- 点击标题可编辑(内联 input
- 优先级下拉可调整
- 取消勾选的条目不会被创建
- `[+ 手动添加]` 新增空白条目
- 底部显示已选数量
### 4.7 确认创建
```typescript
async function confirmBreakdown() {
const selected = pendingSubTasks.value
.filter(s => s.checked)
.map(s => ({ title: s.title, priority: s.priority }));
if (selected.length === 0) {
showToast('请至少选择一个待办项', 'warning');
return;
}
isCreating.value = true;
try {
const resp = await meetingApi.confirmBreakdown(task.id, selected);
showToast(`已成功创建 ${resp.data.createdCount} 个待办项`, 'success');
closeDialog();
emit('updated'); // 刷新任务列表
} catch (err) {
showToast('创建失败,请重试', 'error');
} finally {
isCreating.value = false;
}
}
```
### 4.8 创建任务时的类型选择
`TaskList.vue` 的快速创建表单中增加类型选择:
```
┌──────────────────────────────────────────────┐
│ [+ 新待办项] [_____输入标题_____] │
│ 优先级: [🟡 中 ▼] 类型: [📋 普通 ▼] │
│ [🎙 会议] │
│ [+ 添加] │
└──────────────────────────────────────────────┘
```
默认类型为 `Normal`(普通),选择 `Meeting` 后创建的待办项为会议类型。
## 五、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| 创建 Meeting 类型 | 新建时选择"会议"类型 | 列表中显示 🎙 图标 |
| 会议操作按钮 | 展开 Meeting 类型任务 | 显示录音/纪要/拆分三个按钮 |
| 普通类型不显示 | 查看普通待办项 | 无会议相关按钮 |
| 录音入口 | 点击录音按钮 | 弹出/展开 AudioRecorder 组件 |
| 纪要编辑 | 输入会议纪要后保存 | `meetingNotes` 保存成功 |
| AI 拆分请求 | 点击 AI 拆分按钮 | 加载状态 → 审阅对话框弹出 |
| 无内容拒绝拆分 | 无纪要时点击拆分 | 提示"请先输入纪要" |
| 建议审阅 | 审阅对话框中勾选/取消/编辑 | 操作流畅,状态正确 |
| 手动添加 | 点击"+ 手动添加" | 新增空白条目,可编辑 |
| 批量创建 | 确认勾选的条目 | 子任务创建到父任务下 |
| 离线降级提示 | 离线时点击拆分 | 提示"AI 拆分仅在线可用" |
| 响应式 | 缩小浏览器窗口 | 对话框和控件正常显示 |
## 六、风险
| 风险 | 影响 | 应对 |
|---|---|---|
| 组件嵌套复杂 | TaskItem 已有子任务递归,再加会议 UI 后层级深 | 会议功能用可选插槽方式注入,不影响普通任务渲染 |
| 录音与拆分同时操作 | 用户可能在转写期间又点拆分 | 操作按钮在加载中时禁用 |
| 大量建议渲染 | LLM 返回 10+ 条建议时列表长 | 审阅列表设最大高度 + 滚动;建议上限 15 条 |
## 七、Touch List
| 文件路径 | 修改类型 |
|---|---|
| `src/Hua.Todo.Web/src/types/task.ts` | 修改(新增 TaskType、meetingNotes、audioDuration |
| `src/Hua.Todo.Web/src/api/meeting.ts` | 新增 |
| `src/Hua.Todo.Web/src/components/AudioRecorder.vue` | 新增(由 03-02 产出,本工单集成) |
| `src/Hua.Todo.Web/src/components/MeetingBreakdownDialog.vue` | 新增 |
| `src/Hua.Todo.Web/src/components/TaskItem.vue` | 修改(Meeting 类型条件渲染) |
| `src/Hua.Todo.Web/src/components/TaskList.vue` | 修改(新建任务时类型选择) |
---
**工单编号**03-04
**标题**:任务建议与确认 UI
**版本**v1.3.0
**创建日期**2026-06-16
@@ -0,0 +1,341 @@
# 研发工单 v1.3.0 - 03 会议任务拆分
> 术语澄清:本文件中"研发工单"指智能体/开发者执行的**编码工作项**;"任务/待办项/Todo"指 Hua.Todo 业务领域的 **Todo 待办项**(Task 实体),二者请勿混淆。
---
## 一、目标与范围
### 1.1 目标
实现"会议内容 → AI 任务拆分"功能:用户创建一个标记为"会议"类型的 Todo 待办项作为父目录,通过录音或文字输入会议内容,由 AI 分析后生成待办项建议列表,用户确认后批量创建为子任务。
### 1.2 核心流程
```
用户创建"会议"类型父任务(指定标题,如"周一产品评审会")
选择输入方式:
├─ 录音:录制会议音频 → 系统转写为文字
└─ 文字:直接输入/粘贴会议纪要
AI 分析会议内容 → 生成待办项建议列表
(每条建议含:标题、优先级、可选截止日期)
用户审阅建议列表:
├─ 勾选/取消勾选
├─ 编辑建议内容(修改标题、调整优先级)
└─ 手动补充新条目
确认 → 批量创建为父任务的子待办项
```
### 1.3 范围
**包含**
- "会议"类型标记:创建 Todo 时可指定为会议类型,UI 上以图标/标签区分
- 音频录制:前端录音控件,录制会议音频
- 音频转写:将录音发送至后端,调用 STT 服务转为文字
- 文字输入:直接输入/粘贴会议纪要文本
- AI 任务拆分:将会议文字内容发送给 LLM,生成结构化待办项建议
- 建议审阅与确认 UI:用户勾选、编辑、补充建议后批量创建子任务
- 与工单 02 的 LLM 基础设施复用(`LlmClientService`
**不包含**
- 实时语音转写(边录边转,后续版本考虑)
- 多人协作/会议纪要共享
- 音频文件持久存储(录音转写完成后不保留音频,节省空间;如需保留为后续版本需求)
- 会议录音的云同步(后续版本)
---
## 二、前置条件
| 条件 | 说明 | 状态 |
|---|---|---|
| Hua.Todo v1.2.0 | 基础业务能力(Task CRUD、父子任务) | 已完成 |
| 工单 02 LLM 基础设施 | `LlmClientService`、AI 拆分服务 | 待实现 |
| 浏览器 MediaRecorder API | 前端音频录制 | 已就绪(主流浏览器支持) |
| 后端 STT 服务 | 音频转文字(可复用工单 02 的 STT 能力或调用第三方 API) | 待实现 |
---
## 三、子工单拆分
| 子工单 | 标题 | 依赖 | 可并行 |
|---|---|---|---|
| 03-01 | 会议数据模型与 API | 无 | 是 |
| 03-02 | 音频录制与转写 | 03-01(API 契约) | 部分(前端录音 UI 可并行) |
| 03-03 | AI 任务拆分服务 | 03-01、工单 02 LlmClientService | 部分(prompt 设计可并行) |
| 03-04 | 任务建议与确认 UI | 03-01、03-03 | 否(依赖后端 API 就绪) |
### 执行顺序建议
```
03-01(模型与API) ──────┐
├──→ 03-04(确认UI)
03-02(录制与转写) ──────┤
03-03(AI拆分服务) ──────┘
```
03-01、03-02、03-03 可部分并行推进;03-04 需等前三者 API 就绪后开始。
---
## 四、架构设计
### 4.1 整体架构
```
┌──────────────────────────────────────────────────┐
│ Vue 前端 │
│ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ 录音控件 │ │ 文字输入区 │ │ 建议审阅 │ │
│ │ MediaRecorder│ │ Textarea │ │ 确认对话框 │ │
│ └──────┬──────┘ └──────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌──────────────────────────────┐ │ │
│ │ meetingApi (前端 API 模块) │ │ │
│ └──────────────┬───────────────┘ │ │
└─────────────────┼──────────────────────┼─────────┘
│ HTTP │
▼ │
┌─────────────────────────────────────────┤
│ 后端 (Application 层) │
│ ┌────────────┐ ┌─────────────────┐ │
│ │ Meeting │ │ MeetingAi │ │
│ │ Controller │ │ BreakdownService│ │
│ └─────┬──────┘ └───────┬─────────┘ │
│ │ │ │
│ ┌─────▼──────┐ ┌──────▼─────────┐ │
│ │ Meeting │ │ LlmClient │ │
│ │ Service │ │ Service (复用02)│ │
│ └─────┬──────┘ └────────────────┘ │
│ │ │
│ ┌─────▼──────┐ │
│ │ STT 服务 │ │
│ │ (转写音频) │ │
│ └────────────┘ │
└─────────────────────────────────────────┘
```
### 4.2 数据模型扩展
在现有 `TaskEntity` 上新增 `TaskType` 字段,区分普通待办项与会议:
```csharp
/// <summary>待办项类型枚举</summary>
public enum TaskType
{
/// <summary>普通待办项(默认)</summary>
Normal = 0,
/// <summary>会议(可包含录音、纪要,支持 AI 任务拆分)</summary>
Meeting = 1
}
```
`TaskEntity` 新增字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `TaskType` | `TaskType` | `Normal` | 待办项类型 |
| `MeetingNotes` | `string?` | null | 会议纪要/转写文字(仅 Meeting 类型有值) |
| `AudioDuration` | `double?` | null | 录音时长(秒),仅 Meeting 类型有值 |
> 注意:音频文件本身不持久存储,转写完成后仅保留文字结果。`AudioDuration` 用于 UI 展示录音时长。
### 4.3 API 设计
#### 4.3.1 创建会议类型待办项
```
POST /api/task
请求体:
{
"title": "周一产品评审会",
"priority": 1,
"taskType": 1 // TaskType.Meeting = 1
}
响应体:
{
"success": true,
"data": {
"id": 42,
"title": "周一产品评审会",
"taskType": 1,
"meetingNotes": null,
"audioDuration": null,
...
}
}
```
#### 4.3.2 上传录音并转写
```
POST /api/meeting/{taskId}/transcribe
Content-Type: multipart/form-data
字段:
- audio: 音频文件(webm/wav/mp3
- format: 音频格式(可选,默认从文件扩展名推断)
响应体:
{
"success": true,
"data": {
"taskId": 42,
"transcript": "今天的产品评审会主要讨论了三个议题:第一...",
"audioDuration": 1830.5
}
}
```
#### 4.3.3 保存会议纪要(文字输入)
```
POST /api/meeting/{taskId}/notes
请求体:
{
"notes": "今天的产品评审会主要讨论了三个议题:第一..."
}
响应体:
{
"success": true,
"data": {
"taskId": 42,
"meetingNotes": "今天的产品评审会主要讨论了三个议题:第一..."
}
}
```
#### 4.3.4 AI 任务拆分建议
```
POST /api/meeting/{taskId}/breakdown
请求体:
{
"notes": "..." // 可选,若不传则使用已保存的 meetingNotes
}
响应体:
{
"success": true,
"data": {
"taskId": 42,
"suggestions": [
{
"title": "整理产品需求文档",
"priority": 2,
"reason": "会议中提到需要在本周五前完成需求文档的整理"
},
{
"title": "安排技术方案评审",
"priority": 1,
"reason": "张三负责在下周三前输出技术方案"
},
{
"title": "跟进客户反馈",
"priority": 0,
"reason": "李四反馈了三个客户问题,需后续跟进"
}
]
}
}
```
#### 4.3.5 确认并批量创建子任务
```
POST /api/meeting/{taskId}/breakdown/confirm
请求体:
{
"subTasks": [
{ "title": "整理产品需求文档", "priority": 2 },
{ "title": "安排技术方案评审", "priority": 1 }
]
}
响应体:
{
"success": true,
"data": {
"createdCount": 2,
"subTasks": [
{ "id": 43, "title": "整理产品需求文档", "priority": 2, "parentTaskId": 42 },
{ "id": 44, "title": "安排技术方案评审", "priority": 1, "parentTaskId": 42 }
]
}
}
```
---
## 五、与工单 02 的边界
| 能力 | 工单 02 | 工单 03 |
|---|---|---|
| LLM 调用 | `LlmClientService` 基础设施 | 复用 `LlmClientService`,新增会议拆分专用 prompt |
| 语音输入 | STT 平台原生能力(语音指令) | 前端 MediaRecorder 录音 → 后端 STT 转写(场景不同) |
| AI 拆分 | 对已有单个任务做拆分(`AI_BREAKDOWN` 意图) | 对会议内容做拆分(输入为长文本,输出为多条建议) |
| 确认流程 | 语音确认/点选候选 | 专门的审阅 UI(勾选、编辑、补充) |
**复用关系**
- `LlmClientService`:03 直接复用 02 的 LLM 调用封装
- `AiBreakdownService`:02 的单任务拆分服务,03 不直接复用(输入形式和输出结构不同),但设计上保持一致的调用模式
---
## 六、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| 创建会议类型待办项 | 创建 Todo 时选择"会议"类型 | 创建成功,列表中显示会议图标/标签 |
| 录音功能 | 点击录音按钮,录制一段音频 | 录音控件正常工作,显示录音时长 |
| 录音转写 | 录音完成后提交 | 后端返回转写文字,文字内容基本准确 |
| 文字输入纪要 | 在会议待办项中粘贴会议纪要 | 保存成功,再次打开可见纪要内容 |
| AI 拆分建议 | 提交会议内容请求 AI 拆分 | 返回 3-10 条建议,每条含标题+优先级+理由 |
| 建议审阅 | 勾选/取消/编辑建议 | UI 支持勾选、编辑标题和优先级 |
| 批量创建 | 确认勾选的建议 | 子任务批量创建到父任务下 |
| 离线降级 | 离线时请求 AI 拆分 | 提示"离线模式不支持 AI 拆分" |
| 非会议类型 | 普通待办项不显示录音/拆分入口 | 功能入口仅对 Meeting 类型显示 |
---
## 七、风险与回滚
| 风险 | 影响 | 应对策略 |
|---|---|---|
| STT 转写准确率不足 | 会议纪要质量差,影响 AI 拆分效果 | 支持文字输入作为备选;转写后允许用户编辑修正 |
| LLM API 不稳定 | AI 拆分功能不可用 | 功能降级,其他会议功能(录音、纪要)不受影响 |
| 录音文件过大 | 上传超时/存储压力大 | 前端限制录音时长(建议最长 2 小时);压缩音频格式 |
| 浏览器 MediaRecorder 兼容性 | 部分平台录音功能不可用 | 降级提示"当前浏览器不支持录音,请使用文字输入" |
---
## 八、Touch List
| 文件路径 | 修改类型 | 是否共享 | 说明 |
|---|---|---|---|
| `src/Hua.Todo.Core/Entities/TaskType.cs` | 新增 | 否 | TaskType 枚举 |
| `src/Hua.Todo.Core/Entities/TaskEntity.cs` | 修改 | 是 | 新增 TaskType/MeetingNotes/AudioDuration 字段 |
| `src/Hua.Todo.Application/Data/TodoDbContext.cs` | 修改 | 是 | 新增字段映射 + 迁移 |
| `src/Hua.Todo.Application/Models/TaskModels.cs` | 修改 | 是 | DTO 扩展(CreateTaskDto/TaskDto 新增字段) |
| `src/Hua.Todo.Application/Meeting/` | 新增目录 | 否 | 会议相关服务目录 |
| `src/Hua.Todo.Application/Meeting/MeetingController.cs` | 新增 | 否 | 会议 API 端点 |
| `src/Hua.Todo.Application/Meeting/MeetingService.cs` | 新增 | 否 | 会议业务逻辑 |
| `src/Hua.Todo.Application/Meeting/MeetingAiBreakdownService.cs` | 新增 | 否 | 会议 AI 拆分服务 |
| `src/Hua.Todo.Application/Meeting/SttService.cs` | 新增 | 否 | 音频转写服务 |
| `src/Hua.Todo.Application/Meeting/Models/` | 新增 | 否 | 会议相关 DTO |
| `src/Hua.Todo.Web/src/api/meeting.ts` | 新增 | 否 | 前端会议 API 模块 |
| `src/Hua.Todo.Web/src/composables/useAudioRecorder.ts` | 新增 | 否 | 前端录音组合式函数 |
| `src/Hua.Todo.Web/src/components/MeetingBreakdownDialog.vue` | 新增 | 否 | 会议任务拆分审阅对话框 |
| `src/Hua.Todo.Web/src/components/MeetingTaskItem.vue` | 新增 | 否 | 会议类型待办项(含录音/纪要入口) |
| `src/Hua.Todo.Web/src/components/AudioRecorder.vue` | 新增 | 否 | 录音控件 |
| `src/Hua.Todo.Web/src/types/task.ts` | 修改 | 是 | 类型扩展(taskType/meetingNotes |
---
**工单编号**03
**标题**:会议任务拆分
**版本**v1.3.0
**创建日期**2026-06-16
@@ -0,0 +1,554 @@
# 研发工单 v1.3.0 - 04 富文本描述、附件与外部链接
> 术语澄清:本文件中"研发工单"指智能体/开发者执行的**编码工作项**;"任务/待办项/Todo"指 Hua.Todo 业务领域的 **Todo 待办项**(Task 实体),二者请勿混淆。
---
## 一、目标与范围
### 1.1 目标
当前 Todo 待办项仅包含单行标题(`Title`),无法满足"一个待办项关联更多上下文信息"的需求。本工单为 Todo 待办项新增三项能力:
| 能力 | 描述 |
|---|---|
| **多行描述(Description** | 每个待办项可附带一段多行文字描述,用于记录详细说明、步骤、备注等 |
| **文件附件(Attachments** | 可为一个待办项关联多个本地文件,支持上传、查看、下载、删除 |
| **外部链接与程序启动** | 附件可以是外部 URL(点击在系统默认浏览器打开)或本地文件路径/可执行文件(点击通过系统关联程序打开) |
### 1.2 平台范围
| 平台 | 是否支持 | 说明 |
|---|---|---|
| WindowsMAUI) | ✅ 支持 | 全功能:描述编辑、附件管理、外部程序/链接打开 |
| LinuxAvalonia | ✅ 支持 | 全功能;`xdg-open` 打开外部资源 |
| macOSMAUI | 待定 | 视为 Windows 同级,但本期不单独投入 |
| iOS / Android | ❌ 不支持 | 移动端不提供附件/描述编辑入口,但**移动端编辑待办项时不得覆盖/清空已有描述和附件数据**(详见 1.4 节) |
### 1.3 范围
**包含**
- `TaskEntity` 新增 `Description` 字段(多行文本)
- 新增 `AttachmentEntity` 数据模型(附件元数据)
- 附件 CRUD API(上传、列表、下载、删除、打开)
- 前端编辑对话框扩展(描述 textarea + 附件管理区域)
- 前端待办项列表/详情展示(描述预览、附件数量角标)
- 桌面端通过系统关联程序打开附件(URL → 浏览器,文件路径 → 关联应用)
**不包含**
- 附件云同步(本期不涉及,后续版本规划)
- 附件预览(如图片缩略图、PDF 内嵌预览,本期不做)
- 移动端附件管理(本期仅桌面端)
- 富文本编辑器(如 Markdown 渲染,本期仅纯文本多行)
### 1.4 跨平台数据安全:移动端编辑不得破坏已有数据(强制)
**背景**:移动端(iOS/Android)不提供描述/附件的编辑 UI,但用户仍可在移动端修改待办项标题、优先级、完成状态等基础字段。如果没有防护,移动端发起更新请求时会用"空值"覆盖掉桌面端已设置的 `Description``Attachments`,导致数据丢失。
**约束**
| 约束项 | 要求 |
|---|---|
| **后端 API 必须支持真正的部分更新** | `PUT /api/task/{id}` 的请求体中,**未传递的字段保持原值不变**,不得将缺失字段视为"置空"。即:`description` 不在 JSON 中 → 不修改数据库中的 `Description``description``null` → 清空 |
| **UpdateTaskDto 所有扩展字段均为 optional** | `description``attachments` 相关字段在 DTO 中均标记为可选(`string?` / `null`),与必填字段(`id`)区分 |
| **移动端前端不传递未知字段** | 移动端构建 `UpdateTaskDto` 时只传 `id``title``priority``isCompleted`,不传 `description``attachments`。后端对这些字段视为"不修改" |
| **Attachment 实体独立于 Task 更新** | 附件 CRUD 走独立端点(`/api/task/{id}/attachments`),不通过 `PUT /api/task/{id}` 携带。移动端不调附件端点,自然不会破坏附件数据 |
**验证方式**
1. 桌面端创建待办项,添加描述 + 上传附件
2. 移动端编辑同一待办项(只改标题),保存
3. 回到桌面端查看 → 描述和附件完整保留,未被覆盖
---
## 二、前置条件
| 条件 | 说明 | 状态 |
|---|---|---|
| Hua.Todo v1.2.0 | 基础业务能力(Task CRUD、父子任务) | 已完成 |
| 工单 03 TaskType 枚举 | `TaskEntity` 已有字段扩展模式可参考 | 待实现(03 先于 04 或并行) |
| 桌面 WebView 文件选择 | 前端 `<input type="file">` 能力 | 已就绪 |
| 桌面程序启动 | 通过后端 `Process.Start` / `xdg-open` 实现 | 需新增 |
---
## 三、功能入口覆盖检查
> 依据 [.trae/rules/项目/05-多入口功能同步规范.md](../../../.trae/rules/项目/05-多入口功能同步规范.md),新功能必须检查两个入口覆盖情况。
| 入口 | 已规划 | 方案 |
|---|---|---|
| UI | ✅ | 编辑对话框新增描述 textarea + 附件列表区域;TaskItem 卡片显示描述预览与附件数量角标 |
| 语音 | ⚠️ 部分 | 语音可追加/编辑描述文本(通过"给「标题」添加描述"意图);附件上传与外部程序启动不适合语音入口 |
> 语音入口覆盖说明:
> - **支持**:通过语音添加/修改待办项描述("给「XXX」添加备注:..."
> - **不支持**:语音上传附件、语音打开外部程序 —— 两者本质上是可视化交互,语音不是合适的入口。本期不覆盖。
> - TODO:工单 02 的 LLM intent parser prompt 需预留 `SET_DESCRIPTION` 意图,参数为 `targetTask` + `description`
---
## 四、数据模型设计
### 4.1 TaskEntity 扩展
在现有 `TaskEntity` 上新增一个字段:
```csharp
/// <summary>
/// 多行描述文本(可为空)。
/// 用于记录待办项相关的详细说明、步骤或备注。
/// </summary>
public string? Description { get; set; }
```
| 字段 | 类型 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|
| `Description` | `string?` | null | 最大 5000 字符 | 多行描述文本 |
### 4.2 AttachmentEntity(新增)
`Hua.Todo.Core/Entities/` 下新增 `AttachmentEntity.cs`
```csharp
/// <summary>附件类型枚举</summary>
public enum AttachmentType
{
/// <summary>本地文件(已复制到应用数据目录)</summary>
LocalFile = 0,
/// <summary>外部链接(URL</summary>
ExternalLink = 1
}
/// <summary>附件实体,表示待办项关联的文件或外部链接</summary>
public class AttachmentEntity
{
/// <summary>附件唯一标识符</summary>
public int Id { get; set; }
/// <summary>所属待办项ID</summary>
public int 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 类型(如 text/plain、application/pdf),外部链接为空字符串</summary>
public string ContentType { get; set; } = string.Empty;
/// <summary>附件类型</summary>
public AttachmentType AttachmentType { get; set; } = AttachmentType.LocalFile;
/// <summary>创建时间(UTC</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public TaskEntity Task { get; set; } = null!;
}
```
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Id` | `int` | 自增 | 主键 |
| `TaskId` | `int` | - | FK → Tasks.Id |
| `FileName` | `string` | - | 显示名称,最大 256 字符 |
| `FilePath` | `string` | - | 存储路径或外部 URL,最大 1024 字符 |
| `FileSize` | `long` | 0 | 字节数 |
| `ContentType` | `string` | "" | MIME 类型 |
| `AttachmentType` | `AttachmentType` | `LocalFile` | 本地文件 vs 外部链接 |
| `CreatedAt` | `DateTime` | `UtcNow` | 创建时间 |
`TaskEntity` 新增导航属性:
```csharp
public List<AttachmentEntity> Attachments { get; set; } = new();
```
### 4.3 数据库表
| 表名 | 说明 |
|---|---|
| `T_Attachments` | 附件表 |
> 注:表名沿用 ABP 模板规范 `T_{实体名}s`(见 [.trae/rules/项目/03-数据模型与迁移约束.md](../../../.trae/rules/项目/03-数据模型与迁移约束.md))。
### 4.4 附件存储策略
| 策略项 | 决定 |
|---|---|
| 存储位置 | 应用数据目录下 `Attachments/` 子目录(与数据库 `.db` 同级) |
| 文件命名 | `{attachmentId}_{originalFileName}`,避免重名冲突 |
| 文件大小上限 | 单文件 50MB(`appsettings.json` 可配置) |
| 总附件数上限 | 每个待办项最多 20 个附件 |
| 外部链接 | 不复制文件,仅存储 URL 字符串;打开时调用系统默认浏览器 |
---
## 五、API 设计
### 5.1 已修改:创建/更新待办项(扩展 Description
```
POST /api/task
请求体新增字段:
{
"title": "...",
"priority": 1,
"description": "多行描述文本(可选)" // ← 新增
}
PUT /api/task/{id}
请求体新增字段(所有扩展字段均为可选):
{
"id": 42,
"title": "...",
"priority": 1,
"description": "..." // ← 可选;不传=保持原值,传 null=清空
}
```
> **部分更新语义(强制)**:`PUT` 端点必须实现"仅更新已传递字段"的逻辑。
> - `description` 字段**不在 JSON 中** → 数据库 `Description` 保持原值
> - `description` 字段**为 `null`** → 数据库 `Description` 清空为 `null`
> - 此设计确保移动端不传递 `description` 时不会意外清空已有描述
### 5.2 附件上传
```
POST /api/task/{taskId}/attachments
Content-Type: multipart/form-data
字段:
- file: 文件内容(必填,最大 50MB)
响应体:
{
"success": true,
"data": {
"id": 1,
"taskId": 42,
"fileName": "需求文档.pdf",
"fileSize": 204800,
"contentType": "application/pdf",
"attachmentType": 0,
"createdAt": "2026-06-16T10:00:00Z"
}
}
```
### 5.3 添加外部链接
```
POST /api/task/{taskId}/attachments/link
请求体:
{
"url": "https://example.com/doc",
"fileName": "参考文档" // 可选,不传则使用 URL 作为显示名
}
响应体:
{
"success": true,
"data": {
"id": 2,
"taskId": 42,
"fileName": "参考文档",
"filePath": "https://example.com/doc",
"fileSize": 0,
"contentType": "",
"attachmentType": 1,
"createdAt": "2026-06-16T10:00:00Z"
}
}
```
### 5.4 获取附件列表
```
GET /api/task/{taskId}/attachments
响应体:
{
"success": true,
"data": [
{
"id": 1,
"fileName": "需求文档.pdf",
"fileSize": 204800,
"contentType": "application/pdf",
"attachmentType": 0,
"createdAt": "2026-06-16T10:00:00Z"
},
{
"id": 2,
"fileName": "参考文档",
"filePath": "https://example.com/doc",
"attachmentType": 1,
"createdAt": "2026-06-16T10:05:00Z"
}
]
}
```
### 5.5 下载附件
```
GET /api/attachments/{attachmentId}/download
响应:文件流(Content-Disposition: attachment; filename="需求文档.pdf"
```
### 5.6 打开附件(桌面端)
```
POST /api/attachments/{attachmentId}/open
响应体:
{
"success": true,
"data": {
"opened": true
}
}
// 后端行为:
// - AttachmentType.LocalFile → Process.Start(filePath)Windows)或 xdg-openLinux
// - AttachmentType.ExternalLink → Process.Start(url)(在默认浏览器打开)
```
### 5.7 删除附件
```
DELETE /api/task/{taskId}/attachments/{attachmentId}
响应体:
{
"success": true,
"message": "附件已删除"
}
```
---
## 六、前端设计
### 6.1 类型扩展
`src/Hua.Todo.Web/src/types/task.ts` 新增 / 修改:
```typescript
// Task 接口新增字段
export interface Task {
// ... 既有字段
description?: string; // 多行描述
attachments?: AttachmentItem[]; // 附件列表
attachmentCount?: number; // 附件数量(列表视图用,不传完整列表)
}
export interface AttachmentItem {
id: number;
taskId: number;
fileName: string;
filePath: string;
fileSize: number;
contentType: string;
attachmentType: AttachmentType; // 0=本地文件, 1=外部链接
createdAt: string;
}
export type AttachmentType = 0 | 1;
```
### 6.2 组件变更
| 组件 | 变更类型 | 说明 |
|---|---|---|
| `TaskEditDialog.vue` | 修改 | 新增描述 textarea + 附件管理区域(上传按钮、附件列表) |
| `TaskItem.vue` | 修改 | 卡片底部显示描述预览(单行截断)+ 附件数量角标 |
| `AttachmentList.vue` | 新增 | 附件列表组件:文件名、大小、类型图标、删除按钮、打开按钮 |
| `LinkInputDialog.vue` | 新增 | 外部链接输入弹出框(URL + 显示名称) |
### 6.3 新增 API 模块
`src/Hua.Todo.Web/src/api/attachments.ts`
```typescript
// uploadAttachment(taskId, file) — POST multipart/form-data
// addLink(taskId, url, fileName?) — POST /api/task/{id}/attachments/link
// getAttachments(taskId) — GET
// deleteAttachment(taskId, attId) — DELETE
// openAttachment(attId) — POST /api/attachments/{id}/open
// downloadAttachment(attId) — GET /api/attachments/{id}/download
```
### 6.4 交互流程
```
编辑待办项对话框
├─ 标题输入框(现有)
├─ 优先级选择(现有)
├─ 描述 textarea(新增:多行文本,placeholder "添加备注、步骤说明..."
├─ 附件区域(新增)
│ ├─ [+ 上传文件] 按钮 → 触发文件选择器 → 上传至后端
│ ├─ [+ 添加链接] 按钮 → 弹出 URL 输入框 → 保存为外部链接附件
│ └─ 附件列表(每项显示:图标 + 文件名 + 大小 + [打开] [删除])
│ ├─ 本地文件:[打开] → 调用后端 open API → 系统关联程序打开
│ ├─ 外部链接:[打开] → 调用后端 open API → 浏览器打开
│ └─ [删除] → 确认 → 删除附件
└─ [取消] [保存]
```
### 6.5 列表卡片展示
```
┌─────────────────────────────────────┐
│ ☐ 整理产品需求文档 │
│ 高优先级 │
│ 需要在本周五前完成需求文档的整理... │ ← 描述预览(单行,超长截断)
│ 📎 2 个附件 │ ← 附件数量角标
└─────────────────────────────────────┘
```
---
## 七、后端架构
### 7.1 新增服务
| 服务/类 | 位置 | 说明 |
|---|---|---|
| `AttachmentEntity` | `Hua.Todo.Core/Entities/` | 附件实体 |
| `AttachmentType` | `Hua.Todo.Core/Entities/` | 附件类型枚举 |
| `IAttachmentRepository` | `Hua.Todo.Core/Repositories/` | 仓储接口 |
| `AttachmentRepository` | `Hua.Todo.Application/Data/` | EF Core 仓储实现 |
| `AttachmentService` | `Hua.Todo.Application/Services/` | 附件业务逻辑(上传、下载、打开、清理) |
| `IAttachmentService` | `Hua.Todo.Application/Services/` | 服务接口 |
| `AttachmentController` | `Hua.Todo.Application/DynamicApi/` 或独立 Controller | 附件动态 API 端点(或手动 Controller |
| `PlatformAttachmentOpener` | `Hua.Todo.{Maui,Avalonia}/Services/` | 平台特定文件/链接打开实现(`Process.Start` / `xdg-open` |
### 7.2 平台差异:外部程序启动
| 平台 | 实现方式 |
|---|---|
| WindowsMAUI | `System.Diagnostics.Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true })` |
| LinuxAvalonia | `System.Diagnostics.Process.Start("xdg-open", path)` |
通过 `IPlatformAttachmentOpener` 接口在 Core 定义,MAUI / Avalonia 宿主各自实现并注册。
### 7.3 附件文件管理
- 上传的本地文件复制到 `<AppData>/Hua.Todo/Attachments/{attachmentId}_{originalFileName}`
- 删除附件时同步删除磁盘文件
- 删除待办项时级联删除其所有附件(实体 + 文件)
- 启动时验证附件文件完整性(数据库有记录但文件缺失 → 标记为失效,UI 提示)
### 7.4 安全约束
| 约束 | 说明 |
|---|---|
| 文件类型白名单 | 不限制(本地工具场景,用户自行管理) |
| 文件大小上限 | 50MB(可配置) |
| 路径遍历防护 | 文件名去除 `../``..\` 等危险路径片段 |
| 外部链接验证 | 仅允许 `http://``https://` 协议 |
---
## 八、子工单拆分
本工单体积适中,不拆分子工单,单文件完整实现。
如需与工单 03 并行推进,注意以下共享文件:
| 共享文件 | Writer | 说明 |
|---|---|---|
| `TaskEntity.cs` | **工单 03** 先写入 | 03 新增 TaskType/MeetingNotes/AudioDuration04 后续追加 Description/Attachments 导航属性 |
| `todoDbContext.cs` | **工单 03** 先写入 | 03 新增 TaskType 映射;04 后续追加 Attachments DbSet + 关系映射 |
| `task.ts` | **工单 03** 先写入 | 03 新增 taskType04 后追加 description、attachments |
| `TaskEditDialog.vue` | 协调 | 03 新增 MeetingType 条件渲染;04 追加描述 + 附件区域 |
> 执行顺序:优先完成 03-01(数据模型),再开始 04 的后端模型部分。前端组件部分可独立并行。
---
## 九、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| 描述编辑 | 编辑待办项,在描述 textarea 输入多行文字,保存 | 再次打开编辑框,描述内容完整保留 |
| 描述显示 | 查看待办项列表/详情 | 列表卡片显示描述预览(单行截断),详情显示完整描述 |
| 附件上传 | 编辑待办项,点击上传文件,选择一个本地文件 | 文件上传成功,附件列表显示文件名和大小 |
| 附件下载 | 在附件列表点击下载按钮 | 文件以原始文件名下载到本地 |
| 附件删除 | 点击附件删除按钮 | 附件从列表移除,磁盘文件同步清理 |
| 外部链接添加 | 点击添加链接,输入 URL | 链接保存为附件,类型标记为"外部链接" |
| 打开本地文件 | 点击本地附件"打开" | 系统关联程序打开该文件(如 PDF → PDF 阅读器) |
| 打开外部链接 | 点击外部链接附件"打开" | 系统默认浏览器打开该 URL |
| 打开可执行文件 | 附件为 `.exe`Windows)或可执行脚本(Linux) | 系统正常运行该程序 |
| 附件数量限制 | 超过 20 个附件后继续上传 | 提示"每个待办项最多 20 个附件" |
| 文件大小限制 | 上传超过 50MB 的文件 | 提示"文件大小不能超过 50MB" |
| 待办项删除级联 | 删除有附件的待办项 | 附件记录和磁盘文件同步清理 |
| 空描述 | 创建待办项时不填描述 | 正常创建,列表中不显示描述行 |
| 跨平台安全 | 桌面端设置描述+附件 → 移动端只改标题并保存 → 桌面端查看 | 描述和附件完整保留,未被覆盖(详见 1.4 节) |
---
## 十、风险与回滚
| 风险 | 影响 | 应对策略 |
|---|---|---|
| `Process.Start` 在不同 Linux 发行版行为差异 | 部分 Linux 文件打开失败 | 用 `xdg-open` 兜底;提供错误提示"无法打开文件:{原因}" |
| 附件文件被用户手动删除 | 数据库有记录但文件不存在 | 启动时校验,缺失文件标记为"失效",UI 用灰色 + 感叹号提示 |
| 大文件上传耗尽磁盘空间 | 应用数据目录磁盘满 | 单文件 50MB + 每待办项 20 个上限 = 最多 1GB;检查和提示剩余空间 |
| 并发编辑附件冲突 | 多窗口同时删除/添加附件 | 附件操作为即时保存(非批量提交),利用数据库事务隔离 |
---
## 十一、Touch List
| 文件路径 | 修改类型 | 是否共享 | 说明 |
|---|---|---|---|
| `src/Hua.Todo.Core/Entities/TaskEntity.cs` | 修改 | 是(与 03 共享) | 新增 Description 字段 + Attachments 导航属性 |
| `src/Hua.Todo.Core/Entities/AttachmentEntity.cs` | 新增 | 否 | 附件实体 |
| `src/Hua.Todo.Core/Entities/AttachmentType.cs` | 新增 | 否 | 附件类型枚举 |
| `src/Hua.Todo.Core/Repositories/IAttachmentRepository.cs` | 新增 | 否 | 附件仓储接口 |
| `src/Hua.Todo.Application/Data/TodoDbContext.cs` | 修改 | 是(与 03 共享) | 新增 Attachments DbSet + 关系映射 |
| `src/Hua.Todo.Application/Services/IAttachmentService.cs` | 新增 | 否 | 附件服务接口 |
| `src/Hua.Todo.Application/Services/AttachmentService.cs` | 新增 | 否 | 附件业务逻辑 |
| `src/Hua.Todo.Application/Controllers/AttachmentController.cs` | 新增 | 否 | 附件 API 端点 |
| `src/Hua.Todo.Application/Models/AttachmentModels.cs` | 新增 | 否 | 附件 DTO |
| `src/Hua.Todo.Core/Services/IPlatformAttachmentOpener.cs` | 新增 | 否 | 平台文件打开接口 |
| `src/Hua.Todo.Maui/Services/Platforms/MauiAttachmentOpener.cs` | 新增 | 否 | Windows MAUI 文件打开实现 |
| `src/Hua.Todo.Avalonia/Services/Platforms/AvaloniaAttachmentOpener.cs` | 新增 | 否 | Linux Avalonia 文件打开实现 |
| `src/Hua.Todo.Web/src/api/attachments.ts` | 新增 | 否 | 前端附件 API 模块 |
| `src/Hua.Todo.Web/src/types/task.ts` | 修改 | 是(与 03 共享) | 新增 description、AttachmentItem 等类型 |
| `src/Hua.Todo.Web/src/components/TaskEditDialog.vue` | 修改 | 是(与 03 共享) | 新增描述 textarea + 附件管理区域 |
| `src/Hua.Todo.Web/src/components/TaskItem.vue` | 修改 | 否 | 描述预览 + 附件角标 |
| `src/Hua.Todo.Web/src/components/AttachmentList.vue` | 新增 | 否 | 附件列表组件 |
| `src/Hua.Todo.Web/src/composables/useAttachments.ts` | 新增 | 否 | 附件管理组合式函数 |
---
## 十二、与工单 03 的交互
| 维度 | 工单 03 | 工单 04 |
|---|---|---|
| 描述文本 | `MeetingNotes`(会议纪要,仅会议类型) | `Description`(通用多行描述,所有类型) |
| 附件 | 不涉及 | 通用附件管理 |
| 外部链接 | 不涉及 | 通用外部链接 |
> `MeetingNotes` 与 `Description` 是两个独立字段:
> - `MeetingNotes`:会议转写/文字纪要,可能很长(数千字),仅在会议类型下有值
> - `Description`:通用简短备注(上限 5000 字符),所有类型的待办项都可以有
>
> 两者可共存:会议类型的待办项可以同时有 `MeetingNotes`AI 拆分的输入)和 `Description`(用户的简短备注)。
---
**工单编号**04
**标题**:富文本描述、附件与外部链接
**版本**v1.3.0
**创建日期**2026-06-16
+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,22 +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="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" />
<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
}

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